This commit is contained in:
Eryone 2021-10-27 17:33:21 +08:00
commit 4c6b8a6e1d
5073 changed files with 105394 additions and 26081 deletions

View file

@ -1,5 +1,5 @@
---
name: Bug report
name: Old Bug report
about: Create a report to help us fix issues.
title: ''
labels: 'Type: Bug'

View file

@ -1,7 +1,6 @@
name: Bug Report
description: Create a report to help us fix issues.
labels: "Type: Bug"
issue_body: true
body:
- type: markdown
attributes:
@ -15,7 +14,7 @@ body:
attributes:
label: Application Version
description: The version of Cura this issue occurs with.
placeholder: 4.8.0
placeholder: 4.9.0
validations:
required: true
- type: input
@ -56,13 +55,28 @@ body:
- type: markdown
attributes:
value: |
## Additional information & file uploads
Please be sure to add the following files:
* For slicing issues, upload a **project file** that clearly shows the bug.
To save a project file go to `File -> Save project`. Please make sure to .zip your project file. For big files you may need to use WeTransfer or similar file sharing sites.
G-code files are not project files!
* **Screenshots** of showing the problem, perhaps before/after images.
* A **log file**, see [here](https://github.com/Ultimaker/Cura#logging-issues) how to find the log file.
You can add these files and additional information that is relevant to the issue in the comments below.
* A **log file** for crashes and similar issues.
You can find your log file here:
Windows: `%APPDATA%\cura\<Cura version>\cura.log` or usually `C:\Users\\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
MacOS: `$USER/Library/Application Support/cura/<Cura version>/cura.log`
Ubuntu/Linux: `$USER/.local/share/cura/<Cura version>/cura.log`
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
- type: checkboxes
attributes:
label: Checklist of files to include
options:
- label: Log file
- label: Project file
- type: textarea
attributes:
label: Additional information & file uploads
description: You can add these files and additional information that is relevant to the issue in the comments below.
validations:
required: true

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Have questions or need support?
url: https://community.ultimaker.com/
about: Please get in touch on our Ultimaker Community Forum!

View file

@ -1,7 +1,6 @@
name: Feature Request
description: Suggest an idea for this project.
labels: "Type: New Feature"
issue_body: true
body:
- type: markdown
attributes:
@ -28,7 +27,7 @@ body:
- type: textarea
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered. Again, if possible, think about why these alternatives are not working out.
description: A clear and concise description of any alternative solutions or features you've considered. If possible, think about why these alternatives are not working out.
placeholder: The alternatives I've considered are...
validations:
required: true
@ -39,8 +38,7 @@ body:
placeholder: It will affect...
validations:
required: true
- type: markdown
- type: textarea
attributes:
value: |
## Additional information & file uploads
You can add pictures or files to visualize your feature request in the comments below.
label: Additional information & file uploads
description: You can add pictures or files to visualize your feature request in the comments below.

11
CITATION.cff Normal file
View file

@ -0,0 +1,11 @@
# YAML 1.2
---
authors:
cff-version: "1.1.0"
date-released: 2021-06-28
license: "LGPL-3.0"
message: "If you use this software, please cite it using these metadata."
repository-code: "https://github.com/ultimaker/cura/"
title: "Ultimaker Cura"
version: "4.10.0"
...

View file

@ -33,7 +33,7 @@ configure_file(${CMAKE_SOURCE_DIR}/com.ultimaker.cura.desktop.in ${CMAKE_BINARY_
configure_file(cura/CuraVersion.py.in CuraVersion.py @ONLY)
# FIXME: The new FindPython3 finds the system's Python3.6 reather than the Python3.5 that we built for Cura's environment.
# FIXME: The new FindPython3 finds the system's Python3.6 rather than the Python3.5 that we built for Cura's environment.
# So we're using the old method here, with FindPythonInterp for now.
find_package(PythonInterp 3 REQUIRED)

View file

@ -10,13 +10,13 @@ For crashes and similar issues, please attach the following information:
* (On Windows) The log as produced by dxdiag (start -> run -> dxdiag -> save output)
* The Cura GUI log file, located at
* `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
* `$USER/Library/Application Support/cura/<Cura version>/cura.log` (OSX)
* `$USER/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux)
* `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
* `$HOME/Library/Application Support/cura/<Cura version>/cura.log` (OSX)
* `$HOME/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux)
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
For additional support, you could also ask in the #cura channel on FreeNode IRC. For help with development, there is also the #cura-dev channel.
For additional support, you could also ask in the [#cura channel](https://web.libera.chat/#cura) on [libera.chat](https://libera.chat/). For help with development, there is also the [#cura-dev channel](https://web.libera.chat/#cura-dev).
Dependencies
------------
@ -26,10 +26,16 @@ Dependencies
* [PySerial](https://github.com/pyserial/pyserial) Only required for USB printing support.
* [python-zeroconf](https://github.com/jstasiak/python-zeroconf) Only required to detect mDNS-enabled printers.
For a list of required Python packages, with their recommended version, see `requirements.txt`.
This list is not exhaustive at the moment, please check the links in the next section for more details.
Build scripts
-------------
Please check out [cura-build](https://github.com/Ultimaker/cura-build) for detailed building instructions.
If you want to build the entire environment from scratch before building Cura as well, [cura-build-environment](https://github.com/Ultimaker/cura-build) might be a starting point before cura-build. (Again, see cura-build for more details.)
Running from Source
-------------
Please check our [Wiki page](https://github.com/Ultimaker/Cura/wiki/Running-Cura-from-Source) for details about running Cura from source.

View file

@ -4,7 +4,7 @@
include(CTest)
include(CMakeParseArguments)
# FIXME: The new FindPython3 finds the system's Python3.6 reather than the Python3.5 that we built for Cura's environment.
# FIXME: The new FindPython3 finds the system's Python3.6 rather than the Python3.5 that we built for Cura's environment.
# So we're using the old method here, with FindPythonInterp for now.
find_package(PythonInterp 3 REQUIRED)

View file

@ -28,6 +28,6 @@
<image>https://raw.githubusercontent.com/Ultimaker/Cura/master/screenshot.png</image>
</screenshot>
</screenshots>
<url type="homepage">https://ultimaker.com/en/products/cura-software?utm_source=cura&amp;utm_medium=software&amp;utm_campaign=resources</url>
<url type="homepage">https://ultimaker.com/software/ultimaker-cura?utm_source=cura&amp;utm_medium=software&amp;utm_campaign=cura-update-linux</url>
<translation type="gettext">Cura</translation>
</component>

View file

@ -1,7 +1,7 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime
from typing import Optional, Dict, TYPE_CHECKING, Callable
from typing import Any, Optional, Dict, TYPE_CHECKING, Callable
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS
@ -40,12 +40,15 @@ class Account(QObject):
"""
# The interval in which sync services are automatically triggered
SYNC_INTERVAL = 30.0 # seconds
SYNC_INTERVAL = 60.0 # seconds
Q_ENUMS(SyncState)
loginStateChanged = pyqtSignal(bool)
"""Signal emitted when user logged in or out"""
additionalRightsChanged = pyqtSignal("QVariantMap")
"""Signal emitted when a users additional rights change"""
accessTokenChanged = pyqtSignal()
syncRequested = pyqtSignal()
"""Sync services may connect to this signal to receive sync triggers.
@ -58,6 +61,11 @@ class Account(QObject):
manualSyncEnabledChanged = pyqtSignal(bool)
updatePackagesEnabledChanged = pyqtSignal(bool)
CLIENT_SCOPES = "account.user.read drive.backup.read drive.backup.write packages.download " \
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write " \
"library.project.read library.project.write cura.printjob.read cura.printjob.write " \
"cura.mesh.read cura.mesh.write"
def __init__(self, application: "CuraApplication", parent = None) -> None:
super().__init__(parent)
self._application = application
@ -65,6 +73,7 @@ class Account(QObject):
self._error_message = None # type: Optional[Message]
self._logged_in = False
self._additional_rights: Dict[str, Any] = {}
self._sync_state = SyncState.IDLE
self._manual_sync_enabled = False
self._update_packages_enabled = False
@ -79,10 +88,7 @@ class Account(QObject):
CALLBACK_PORT=self._callback_port,
CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port),
CLIENT_ID="um----------------------------ultimaker_cura",
CLIENT_SCOPES="account.user.read drive.backup.read drive.backup.write packages.download "
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write "
"library.project.read library.project.write cura.printjob.read cura.printjob.write "
"cura.mesh.read cura.mesh.write",
CLIENT_SCOPES=self.CLIENT_SCOPES,
AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data",
AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root),
AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root)
@ -107,7 +113,6 @@ class Account(QObject):
self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged)
self._authorization_service.loadAuthDataFromPreferences()
@pyqtProperty(int, notify=syncStateChanged)
def syncState(self):
return self._sync_state
@ -176,7 +181,10 @@ class Account(QObject):
if error_message:
if self._error_message:
self._error_message.hide()
self._error_message = Message(error_message, title = i18n_catalog.i18nc("@info:title", "Login failed"))
Logger.log("w", "Failed to login: %s", error_message)
self._error_message = Message(error_message,
title = i18n_catalog.i18nc("@info:title", "Login failed"),
message_type = Message.MessageType.ERROR)
self._error_message.show()
self._logged_in = False
self.loginStateChanged.emit(False)
@ -207,7 +215,7 @@ class Account(QObject):
if self._update_timer.isActive():
self._update_timer.stop()
elif self._sync_state == SyncState.SYNCING:
Logger.warning("Starting a new sync while previous sync was not completed\n{}", str(self._sync_services))
Logger.debug("Starting a new sync while previous sync was not completed")
self.syncRequested.emit()
@ -297,3 +305,14 @@ class Account(QObject):
return # Nothing to do, user isn't logged in.
self._authorization_service.deleteAuthData()
def updateAdditionalRight(self, **kwargs) -> None:
"""Update the additional rights of the account.
The argument(s) are the rights that need to be set"""
self._additional_rights.update(kwargs)
self.additionalRightsChanged.emit(self._additional_rights)
@pyqtProperty("QVariantMap", notify = additionalRightsChanged)
def additionalRights(self) -> Dict[str, Any]:
"""A dictionary which can be queried for additional account rights."""
return self._additional_rights

View file

@ -13,7 +13,7 @@ DEFAULT_CURA_DEBUG_MODE = False
# Each release has a fixed SDK version coupled with it. It doesn't make sense to make it configurable because, for
# example Cura 3.2 with SDK version 6.1 will not work. So the SDK version is hard-coded here and left out of the
# CuraVersion.py.in template.
CuraSDKVersion = "7.4.0"
CuraSDKVersion = "7.8.0"
try:
from cura.CuraVersion import CuraAppName # type: ignore

View file

@ -147,6 +147,8 @@ class ArrangeObjectsAllBuildPlatesJob(Job):
status_message.hide()
if not found_solution_for_all:
no_full_solution_message = Message(i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"),
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"))
no_full_solution_message = Message(i18n_catalog.i18nc("@info:status",
"Unable to find a location within the build volume for all objects"),
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"),
message_type = Message.MessageType.WARNING)
no_full_solution_message.show()

View file

@ -39,6 +39,7 @@ class ArrangeObjectsJob(Job):
no_full_solution_message = Message(
i18n_catalog.i18nc("@info:status",
"Unable to find a location within the build volume for all objects"),
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"))
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"),
message_type = Message.MessageType.ERROR)
no_full_solution_message.show()
self.finished.emit(self)

View file

@ -36,6 +36,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
node_items: A list of the nodes return by libnest2d, which contain the new positions on the buildplate
"""
spacing = int(1.5 * factor) # 1.5mm spacing.
machine_width = build_volume.getWidth()
machine_depth = build_volume.getDepth()
@ -75,7 +76,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
# Clip the disallowed areas so that they don't overlap the bounding box (The arranger chokes otherwise)
clipped_area = area.intersectionConvexHulls(build_plate_polygon)
if clipped_area.getPoints() is not None: # numpy array has to be explicitly checked against None
if clipped_area.getPoints() is not None and len(clipped_area.getPoints()) > 2: # numpy array has to be explicitly checked against None
for point in clipped_area.getPoints():
converted_points.append(Point(int(point[0] * factor), int(point[1] * factor)))
@ -88,7 +89,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
converted_points = []
hull_polygon = node.callDecoration("getConvexHull")
if hull_polygon is not None and hull_polygon.getPoints() is not None: # numpy array has to be explicitly checked against None
if hull_polygon is not None and hull_polygon.getPoints() is not None and len(hull_polygon.getPoints()) > 2: # numpy array has to be explicitly checked against None
for point in hull_polygon.getPoints():
converted_points.append(Point(point[0] * factor, point[1] * factor))
item = Item(converted_points)
@ -99,7 +100,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
config = NfpConfig()
config.accuracy = 1.0
num_bins = nest(node_items, build_plate_bounding_box, 10000, config)
num_bins = nest(node_items, build_plate_bounding_box, spacing, config)
# Strip the fixed items (previously placed) and the disallowed areas from the results again.
node_items = list(filter(lambda item: not item.isFixed(), node_items))
@ -109,18 +110,11 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
return found_solution_for_all, node_items
def arrange(nodes_to_arrange: List["SceneNode"], build_volume: "BuildVolume", fixed_nodes: Optional[List["SceneNode"]] = None, factor = 10000, add_new_nodes_in_scene: bool = False) -> bool:
"""
Find placement for a set of scene nodes, and move them by using a single grouped operation.
:param nodes_to_arrange: The list of nodes that need to be moved.
:param build_volume: The build volume that we want to place the nodes in. It gets size & disallowed areas from this.
:param fixed_nodes: List of nods that should not be moved, but should be used when deciding where the others nodes
are placed.
:param factor: The library that we use is int based. This factor defines how accuracte we want it to be.
:param add_new_nodes_in_scene: Whether to create new scene nodes before applying the transformations and rotations
:return: found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
"""
def createGroupOperationForArrange(nodes_to_arrange: List["SceneNode"],
build_volume: "BuildVolume",
fixed_nodes: Optional[List["SceneNode"]] = None,
factor = 10000,
add_new_nodes_in_scene: bool = False) -> Tuple[GroupedOperation, int]:
scene_root = Application.getInstance().getController().getScene().getRoot()
found_solution_for_all, node_items = findNodePlacement(nodes_to_arrange, build_volume, fixed_nodes, factor)
@ -142,6 +136,27 @@ def arrange(nodes_to_arrange: List["SceneNode"], build_volume: "BuildVolume", fi
grouped_operation.addOperation(
TranslateOperation(node, Vector(200, node.getWorldPosition().y, -not_fit_count * 20), set_position = True))
not_fit_count += 1
grouped_operation.push()
return found_solution_for_all
return grouped_operation, not_fit_count
def arrange(nodes_to_arrange: List["SceneNode"],
build_volume: "BuildVolume",
fixed_nodes: Optional[List["SceneNode"]] = None,
factor = 10000,
add_new_nodes_in_scene: bool = False) -> bool:
"""
Find placement for a set of scene nodes, and move them by using a single grouped operation.
:param nodes_to_arrange: The list of nodes that need to be moved.
:param build_volume: The build volume that we want to place the nodes in. It gets size & disallowed areas from this.
:param fixed_nodes: List of nods that should not be moved, but should be used when deciding where the others nodes
are placed.
:param factor: The library that we use is int based. This factor defines how accuracte we want it to be.
:param add_new_nodes_in_scene: Whether to create new scene nodes before applying the transformations and rotations
:return: found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
"""
grouped_operation, not_fit_count = createGroupOperationForArrange(nodes_to_arrange, build_volume, fixed_nodes, factor, add_new_nodes_in_scene)
grouped_operation.push()
return not_fit_count != 0

View file

@ -3,7 +3,7 @@
import numpy
import copy
from typing import Optional, Tuple, TYPE_CHECKING
from typing import Optional, Tuple, TYPE_CHECKING, Union
from UM.Math.Polygon import Polygon
@ -14,14 +14,14 @@ if TYPE_CHECKING:
class ShapeArray:
"""Polygon representation as an array for use with :py:class:`cura.Arranging.Arrange.Arrange`"""
def __init__(self, arr: numpy.array, offset_x: float, offset_y: float, scale: float = 1) -> None:
def __init__(self, arr: numpy.ndarray, offset_x: float, offset_y: float, scale: float = 1) -> None:
self.arr = arr
self.offset_x = offset_x
self.offset_y = offset_y
self.scale = scale
@classmethod
def fromPolygon(cls, vertices: numpy.array, scale: float = 1) -> "ShapeArray":
def fromPolygon(cls, vertices: numpy.ndarray, scale: float = 1) -> "ShapeArray":
"""Instantiate from a bunch of vertices
:param vertices:
@ -98,7 +98,7 @@ class ShapeArray:
return offset_shape_arr, hull_shape_arr
@classmethod
def arrayFromPolygon(cls, shape: Tuple[int, int], vertices: numpy.array) -> numpy.array:
def arrayFromPolygon(cls, shape: Union[Tuple[int, int], numpy.ndarray], vertices: numpy.ndarray) -> numpy.ndarray:
"""Create :py:class:`numpy.ndarray` with dimensions defined by shape
Fills polygon defined by vertices with ones, all other values zero
@ -110,7 +110,7 @@ class ShapeArray:
:return: numpy array with dimensions defined by shape
"""
base_array = numpy.zeros(shape, dtype = numpy.int32) # Initialize your array of zeros
base_array = numpy.zeros(shape, dtype = numpy.int32) # type: ignore # Initialize your array of zeros
fill = numpy.ones(base_array.shape) * True # Initialize boolean array defining shape fill
@ -126,7 +126,7 @@ class ShapeArray:
return base_array
@classmethod
def _check(cls, p1: numpy.array, p2: numpy.array, base_array: numpy.array) -> Optional[numpy.array]:
def _check(cls, p1: numpy.ndarray, p2: numpy.ndarray, base_array: numpy.ndarray) -> Optional[numpy.ndarray]:
"""Return indices that mark one side of the line, used by arrayFromPolygon
Uses the line defined by p1 and p2 to check array of

View file

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import QTimer
@ -6,6 +6,8 @@ from typing import Any, TYPE_CHECKING
from UM.Logger import Logger
import time
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
@ -56,8 +58,8 @@ class AutoSave:
def _onTimeout(self) -> None:
self._saving = True # To prevent the save process from triggering another autosave.
Logger.log("d", "Autosaving preferences, instances and profiles")
save_start_time = time.time()
self._application.saveSettings()
Logger.log("d", "Autosaving preferences, instances and profiles took %s seconds", time.time() - save_start_time)
self._saving = False

View file

@ -1,18 +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 io
import os
import re
import shutil
from copy import deepcopy
from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile
from typing import Dict, Optional, TYPE_CHECKING
from typing import Dict, Optional, TYPE_CHECKING, List
from UM import i18nCatalog
from UM.Logger import Logger
from UM.Message import Message
from UM.Platform import Platform
from UM.Resources import Resources
from UM.Version import Version
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
@ -27,6 +29,11 @@ class Backup:
IGNORED_FILES = [r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc"]
"""These files should be ignored when making a backup."""
IGNORED_FOLDERS = [] # type: List[str]
SECRETS_SETTINGS = ["general/ultimaker_auth_data"]
"""Secret preferences that need to obfuscated when making a backup of Cura"""
catalog = i18nCatalog("cura")
"""Re-use translation catalog"""
@ -43,6 +50,9 @@ class Backup:
Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir)
# obfuscate sensitive secrets
secrets = self._obfuscate()
# Ensure all current settings are saved.
self._application.saveSettings()
@ -67,8 +77,9 @@ class Backup:
machine_count = max(len([s for s in files if "machine_instances/" in s]) - 1, 0) # If people delete their profiles but not their preferences, it can still make a backup, and report -1 profiles. Server crashes on this.
material_count = max(len([s for s in files if "materials/" in s]) - 1, 0)
profile_count = max(len([s for s in files if "quality_changes/" in s]) - 1, 0)
plugin_count = len([s for s in files if "plugin.json" in s])
# We don't store plugins anymore, since if you can make backups, you have an account (and the plugins are
# on the marketplace anyway)
plugin_count = 0
# Store the archive and metadata so the BackupManager can fetch them when needed.
self.zip_file = buffer.getvalue()
self.meta_data = {
@ -78,6 +89,8 @@ class Backup:
"profile_count": str(profile_count),
"plugin_count": str(plugin_count)
}
# Restore the obfuscated settings
self._illuminate(**secrets)
def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]:
"""Make a full archive from the given root path with the given name.
@ -85,8 +98,7 @@ class Backup:
:param root_path: The root directory to archive recursively.
:return: The archive as bytes.
"""
ignore_string = re.compile("|".join(self.IGNORED_FILES))
ignore_string = re.compile("|".join(self.IGNORED_FILES + self.IGNORED_FOLDERS))
try:
archive = ZipFile(buffer, "w", ZIP_DEFLATED)
for root, folders, files in os.walk(root_path):
@ -99,15 +111,15 @@ class Backup:
return archive
except (IOError, OSError, BadZipfile) as error:
Logger.log("e", "Could not create archive from user data directory: %s", error)
self._showMessage(
self.catalog.i18nc("@info:backup_failed",
"Could not create archive from user data directory: {}".format(error)))
self._showMessage(self.catalog.i18nc("@info:backup_failed",
"Could not create archive from user data directory: {}".format(error)),
message_type = Message.MessageType.ERROR)
return None
def _showMessage(self, message: str) -> None:
def _showMessage(self, message: str, message_type: Message.MessageType = Message.MessageType.NEUTRAL) -> None:
"""Show a UI message."""
Message(message, title=self.catalog.i18nc("@info:title", "Backup"), lifetime=30).show()
Message(message, title=self.catalog.i18nc("@info:title", "Backup"), message_type = message_type).show()
def restore(self) -> bool:
"""Restore this back-up.
@ -118,24 +130,36 @@ class Backup:
if not self.zip_file or not self.meta_data or not self.meta_data.get("cura_release", None):
# We can restore without the minimum required information.
Logger.log("w", "Tried to restore a Cura backup without having proper data or meta data.")
self._showMessage(
self.catalog.i18nc("@info:backup_failed",
"Tried to restore a Cura backup without having proper data or meta data."))
self._showMessage(self.catalog.i18nc("@info:backup_failed",
"Tried to restore a Cura backup without having proper data or meta data."),
message_type = Message.MessageType.ERROR)
return False
current_version = self._application.getVersion()
version_to_restore = self.meta_data.get("cura_release", "master")
current_version = Version(self._application.getVersion())
version_to_restore = Version(self.meta_data.get("cura_release", "master"))
if current_version < version_to_restore:
# Cannot restore version newer than current because settings might have changed.
Logger.log("d", "Tried to restore a Cura backup of version {version_to_restore} with cura version {current_version}".format(version_to_restore = version_to_restore, current_version = current_version))
self._showMessage(
self.catalog.i18nc("@info:backup_failed",
"Tried to restore a Cura backup that is higher than the current version."))
self._showMessage(self.catalog.i18nc("@info:backup_failed",
"Tried to restore a Cura backup that is higher than the current version."),
message_type = Message.MessageType.ERROR)
return False
# Get the current secrets and store since the back-up doesn't contain those
secrets = self._obfuscate()
version_data_dir = Resources.getDataStoragePath()
archive = ZipFile(io.BytesIO(self.zip_file), "r")
try:
archive = ZipFile(io.BytesIO(self.zip_file), "r")
except LookupError as e:
Logger.log("d", f"The following error occurred while trying to restore a Cura backup: {str(e)}")
Message(self.catalog.i18nc("@info:backup_failed",
"The following error occurred while trying to restore a Cura backup:") + str(e),
title = self.catalog.i18nc("@info:title", "Backup"),
message_type = Message.MessageType.ERROR).show()
return False
extracted = self._extractArchive(archive, version_data_dir)
# Under Linux, preferences are stored elsewhere, so we copy the file to there.
@ -144,7 +168,16 @@ class Backup:
preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name))
backup_preferences_file = os.path.join(version_data_dir, "{}.cfg".format(preferences_file_name))
Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file)
shutil.move(backup_preferences_file, preferences_file)
try:
shutil.move(backup_preferences_file, preferences_file)
except EnvironmentError as e:
Logger.error(f"Unable to back-up preferences file: {type(e)} - {str(e)}")
# Read the preferences from the newly restored configuration (or else the cached Preferences will override the restored ones)
self._application.readPreferencesFromConfiguration()
# Restore the obfuscated settings
self._illuminate(**secrets)
return extracted
@ -167,9 +200,38 @@ class Backup:
Logger.log("d", "Removing current data in location: %s", target_path)
Resources.factoryReset()
Logger.log("d", "Extracting backup to location: %s", target_path)
try:
archive.extractall(target_path)
except (PermissionError, EnvironmentError):
Logger.logException("e", "Unable to extract the backup due to permission or file system errors.")
return False
name_list = archive.namelist()
for archive_filename in name_list:
try:
archive.extract(archive_filename, target_path)
except (PermissionError, EnvironmentError):
Logger.logException("e", f"Unable to extract the file {archive_filename} from the backup due to permission or file system errors.")
except UnicodeEncodeError:
Logger.error(f"Unable to extract the file {archive_filename} because of an encoding error.")
CuraApplication.getInstance().processEvents()
return True
def _obfuscate(self) -> Dict[str, str]:
"""
Obfuscate and remove the secret preferences that are specified in SECRETS_SETTINGS
:return: a dictionary of the removed secrets. Note: the '/' is replaced by '__'
"""
preferences = self._application.getPreferences()
secrets = {}
for secret in self.SECRETS_SETTINGS:
secrets[secret.replace("/", "__")] = deepcopy(preferences.getValue(secret))
preferences.setValue(secret, None)
self._application.savePreferences()
return secrets
def _illuminate(self, **kwargs) -> None:
"""
Restore the obfuscated settings
:param kwargs: a dict of obscured preferences. Note: the '__' of the keys will be replaced by '/'
"""
preferences = self._application.getPreferences()
for key, value in kwargs.items():
preferences.setValue(key.replace("__", "/"), value)
self._application.savePreferences()

View file

@ -4,6 +4,7 @@
from typing import Dict, Optional, Tuple, TYPE_CHECKING
from UM.Logger import Logger
from UM.Version import Version
from cura.Backups.Backup import Backup
if TYPE_CHECKING:
@ -52,6 +53,7 @@ class BackupsManager:
backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data)
restored = backup.restore()
if restored:
# At this point, Cura will need to restart for the changes to take effect.
# We don't want to store the data at this point as that would override the just-restored backup.

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.
import numpy
@ -95,9 +95,11 @@ class BuildVolume(SceneNode):
self._edge_disallowed_size = None
self._build_volume_message = Message(catalog.i18nc("@info:status",
"The build volume height has been reduced due to the value of the"
" \"Print Sequence\" setting to prevent the gantry from colliding"
" with printed models."), title = catalog.i18nc("@info:title", "Build Volume"))
"The build volume height has been reduced due to the value of the"
" \"Print Sequence\" setting to prevent the gantry from colliding"
" with printed models."),
title = catalog.i18nc("@info:title", "Build Volume"),
message_type = Message.MessageType.WARNING)
self._global_container_stack = None # type: Optional[GlobalStack]
@ -916,6 +918,8 @@ class BuildVolume(SceneNode):
return {}
for area in self._global_container_stack.getProperty("machine_disallowed_areas", "value"):
if len(area) == 0:
continue # Numpy doesn't deal well with 0-length arrays, since it can't determine the dimensionality of them.
polygon = Polygon(numpy.array(area, numpy.float32))
polygon = polygon.getMinkowskiHull(Polygon.approximatedCircle(border_size))
machine_disallowed_polygons.append(polygon)
@ -1074,9 +1078,10 @@ class BuildVolume(SceneNode):
# setting does *not* have a limit_to_extruder setting (which means that we can't ask the global extruder what
# the value is.
adhesion_extruder = self._global_container_stack.getProperty("adhesion_extruder_nr", "value")
skirt_brim_line_width = self._global_container_stack.extruderList[int(adhesion_extruder)].getProperty("skirt_brim_line_width", "value")
adhesion_stack = self._global_container_stack.extruderList[int(adhesion_extruder)]
skirt_brim_line_width = adhesion_stack.getProperty("skirt_brim_line_width", "value")
initial_layer_line_width_factor = self._global_container_stack.getProperty("initial_layer_line_width_factor", "value")
initial_layer_line_width_factor = adhesion_stack.getProperty("initial_layer_line_width_factor", "value")
# Use brim width if brim is enabled OR the prime tower has a brim.
if adhesion_type == "brim":
brim_line_count = self._global_container_stack.getProperty("brim_line_count", "value")

View file

@ -35,7 +35,7 @@ class CuraActions(QObject):
# Starting a web browser from a signal handler connected to a menu will crash on windows.
# So instead, defer the call to the next run of the event loop, since that does work.
# Note that weirdly enough, only signal handlers that open a web browser fail like that.
event = CallFunctionEvent(self._openUrl, [QUrl("https://ultimaker.com/en/resources/manuals/software")], {})
event = CallFunctionEvent(self._openUrl, [QUrl("https://ultimaker.com/en/resources/manuals/software?utm_source=cura&utm_medium=software&utm_campaign=dropdown-documentation")], {})
cura.CuraApplication.CuraApplication.getInstance().functionEvent(event)
@pyqtSlot()
@ -67,11 +67,15 @@ class CuraActions(QObject):
current_node = parent_node
parent_node = current_node.getParent()
# This was formerly done with SetTransformOperation but because of
# unpredictable matrix deconstruction it was possible that mirrors
# could manifest as rotations. Centering is therefore done by
# moving the node to negative whatever its position is:
center_operation = TranslateOperation(current_node, -current_node._position)
# Find out where the bottom of the object is
bbox = current_node.getBoundingBox()
if bbox:
center_y = current_node.getWorldPosition().y - bbox.bottom
else:
center_y = 0
# Move the object so that it's bottom is on to of the buildplate
center_operation = TranslateOperation(current_node, Vector(0, center_y, 0), set_position = True)
operation.addOperation(center_operation)
operation.push()

View file

@ -129,7 +129,7 @@ class CuraApplication(QtApplication):
# SettingVersion represents the set of settings available in the machine/extruder definitions.
# You need to make sure that this version number needs to be increased if there is any non-backwards-compatible
# changes of the settings.
SettingVersion = 16
SettingVersion = 19
Created = False
@ -161,7 +161,7 @@ class CuraApplication(QtApplication):
self.default_theme = "cura-light"
self.change_log_url = "https://ultimaker.com/ultimaker-cura-latest-features"
self.change_log_url = "https://ultimaker.com/ultimaker-cura-latest-features?utm_source=cura&utm_medium=software&utm_campaign=cura-update-features"
self._boot_loading_time = time.time()
@ -257,6 +257,9 @@ class CuraApplication(QtApplication):
from cura.CuraPackageManager import CuraPackageManager
self._package_manager_class = CuraPackageManager
from UM.CentralFileStorage import CentralFileStorage
CentralFileStorage.setIsEnterprise(ApplicationMetadata.IsEnterpriseVersion)
@pyqtProperty(str, constant=True)
def ultimakerCloudApiRootUrl(self) -> str:
return UltimakerCloudConstants.CuraCloudAPIRoot
@ -317,7 +320,7 @@ class CuraApplication(QtApplication):
super().initialize()
self._preferences.addPreference("cura/single_instance", False)
self._use_single_instance = self._preferences.getValue("cura/single_instance")
self._use_single_instance = self._preferences.getValue("cura/single_instance") or self._cli_args.single_instance
self.__sendCommandToSingleInstance()
self._initializeSettingDefinitions()
@ -458,15 +461,18 @@ class CuraApplication(QtApplication):
self._version_upgrade_manager.setCurrentVersions(
{
("quality", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityInstanceContainer, "application/x-uranium-instancecontainer"),
("quality_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityChangesInstanceContainer, "application/x-uranium-instancecontainer"),
("intent", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.IntentInstanceContainer, "application/x-uranium-instancecontainer"),
("machine_stack", GlobalStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.MachineStack, "application/x-cura-globalstack"),
("extruder_train", ExtruderStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.ExtruderStack, "application/x-cura-extruderstack"),
("preferences", Preferences.Version * 1000000 + self.SettingVersion): (Resources.Preferences, "application/x-uranium-preferences"),
("user", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.UserInstanceContainer, "application/x-uranium-instancecontainer"),
("definition_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.DefinitionChangesContainer, "application/x-uranium-instancecontainer"),
("variant", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.VariantInstanceContainer, "application/x-uranium-instancecontainer"),
("quality", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityInstanceContainer, "application/x-uranium-instancecontainer"),
("quality_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityChangesInstanceContainer, "application/x-uranium-instancecontainer"),
("intent", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.IntentInstanceContainer, "application/x-uranium-instancecontainer"),
("machine_stack", GlobalStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.MachineStack, "application/x-cura-globalstack"),
("extruder_train", ExtruderStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.ExtruderStack, "application/x-cura-extruderstack"),
("preferences", Preferences.Version * 1000000 + self.SettingVersion): (Resources.Preferences, "application/x-uranium-preferences"),
("user", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.UserInstanceContainer, "application/x-uranium-instancecontainer"),
("definition_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.DefinitionChangesContainer, "application/x-uranium-instancecontainer"),
("variant", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.VariantInstanceContainer, "application/x-uranium-instancecontainer"),
("setting_visibility", SettingVisibilityPresetsModel.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.SettingVisibilityPreset, "application/x-uranium-preferences"),
("machine", 2): (Resources.DefinitionContainers, "application/x-uranium-definitioncontainer"),
("extruder", 2): (Resources.DefinitionContainers, "application/x-uranium-definitioncontainer")
}
)
@ -704,6 +710,8 @@ class CuraApplication(QtApplication):
@pyqtSlot(str)
def discardOrKeepProfileChangesClosed(self, option: str) -> None:
global_stack = self.getGlobalContainerStack()
if global_stack is None:
return
if option == "discard":
for extruder in global_stack.extruderList:
extruder.userChanges.clear()
@ -742,7 +750,9 @@ class CuraApplication(QtApplication):
@pyqtSlot(str, result = QUrl)
def getDefaultPath(self, key):
default_path = self.getPreferences().getValue("local_file/%s" % key)
return QUrl.fromLocalFile(default_path)
if os.path.exists(default_path):
return QUrl.fromLocalFile(default_path)
return QUrl()
@pyqtSlot(str, str)
def setDefaultPath(self, key, default_path):
@ -1304,9 +1314,9 @@ class CuraApplication(QtApplication):
if not isinstance(node, SceneNode):
continue
if not node.getMeshData() and not node.callDecoration("isGroup"):
continue # Node that doesnt have a mesh and is not a group.
continue # Node that doesn't have a mesh and is not a group.
if node.getParent() and node.getParent().callDecoration("isGroup") or node.getParent().callDecoration("isSliceable"):
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
if not node.isSelectable():
continue # i.e. node with layer data
if not node.callDecoration("isSliceable") and not node.callDecoration("isGroup"):
@ -1324,9 +1334,9 @@ class CuraApplication(QtApplication):
if not isinstance(node, SceneNode):
continue
if not node.getMeshData() and not node.callDecoration("isGroup"):
continue # Node that doesnt have a mesh and is not a group.
continue # Node that doesn't have a mesh and is not a group.
if node.getParent() and node.getParent().callDecoration("isGroup"):
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
if not node.isSelectable():
continue # i.e. node with layer data
nodes.append(node)
@ -1353,9 +1363,9 @@ class CuraApplication(QtApplication):
if not isinstance(node, SceneNode):
continue
if not node.getMeshData() and not node.callDecoration("isGroup"):
continue # Node that doesnt have a mesh and is not a group.
continue # Node that doesn't have a mesh and is not a group.
if node.getParent() and node.getParent().callDecoration("isGroup"):
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
if not node.callDecoration("isSliceable") and not node.callDecoration("isGroup"):
continue # i.e. node with layer data
nodes.append(node)
@ -1382,7 +1392,7 @@ class CuraApplication(QtApplication):
continue
if not node.getMeshData() and not node.callDecoration("isGroup"):
continue # Node that doesnt have a mesh and is not a group.
continue # Node that doesn't have a mesh and is not a group.
parent_node = node.getParent()
if parent_node and parent_node.callDecoration("isGroup"):
@ -1410,11 +1420,11 @@ class CuraApplication(QtApplication):
continue
if not node.getMeshData() and not node.callDecoration("isGroup"):
continue # Node that doesnt have a mesh and is not a group.
continue # Node that doesn't have a mesh and is not a group.
parent_node = node.getParent()
if parent_node and parent_node.callDecoration("isGroup"):
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
if not node.isSelectable():
continue # i.e. node with layer data
@ -1525,12 +1535,8 @@ class CuraApplication(QtApplication):
# Compute the center of the objects
object_centers = []
# Forget about the translation that the original objects have
zero_translation = Matrix(data=numpy.zeros(3))
for mesh, node in zip(meshes, group_node.getChildren()):
transformation = node.getLocalTransformation()
transformation.setTranslation(zero_translation)
transformed_mesh = mesh.getTransformed(transformation)
transformed_mesh = mesh.getTransformed(Matrix()) # Forget about the transformations that the original object had.
center = transformed_mesh.getCenterPosition()
if center is not None:
object_centers.append(center)
@ -1545,7 +1551,7 @@ class CuraApplication(QtApplication):
# Move each node to the same position.
for mesh, node in zip(meshes, group_node.getChildren()):
node.setTransformation(Matrix())
node.setTransformation(Matrix()) # Removes any changes in position and rotation.
# Align the object around its zero position
# and also apply the offset to center it inside the group.
node.setPosition(-mesh.getZeroPosition() - offset)
@ -1796,8 +1802,10 @@ class CuraApplication(QtApplication):
if extension in self._non_sliceable_extensions:
message = Message(
self._i18n_catalog.i18nc("@info:status",
"Only one G-code file can be loaded at a time. Skipped importing {0}",
filename), title = self._i18n_catalog.i18nc("@info:title", "Warning"))
"Only one G-code file can be loaded at a time. Skipped importing {0}",
filename),
title = self._i18n_catalog.i18nc("@info:title", "Warning"),
message_type = Message.MessageType.WARNING)
message.show()
return
# If file being loaded is non-slicable file, then prevent loading of any other files
@ -1806,8 +1814,10 @@ class CuraApplication(QtApplication):
if extension in self._non_sliceable_extensions:
message = Message(
self._i18n_catalog.i18nc("@info:status",
"Can't open any other file if G-code is loading. Skipped importing {0}",
filename), title = self._i18n_catalog.i18nc("@info:title", "Error"))
"Can't open any other file if G-code is loading. Skipped importing {0}",
filename),
title = self._i18n_catalog.i18nc("@info:title", "Error"),
message_type = Message.MessageType.ERROR)
message.show()
return
@ -1866,6 +1876,7 @@ class CuraApplication(QtApplication):
else:
node = CuraSceneNode()
node.setMeshData(original_node.getMeshData())
node.source_mime_type = original_node.source_mime_type
# Setting meshdata does not apply scaling.
if original_node.getScale() != Vector(1.0, 1.0, 1.0):
@ -2030,11 +2041,11 @@ class CuraApplication(QtApplication):
if not node.isEnabled():
continue
if (not node.getMeshData() and not node.callDecoration("getLayerData")) and not node.callDecoration("isGroup"):
continue # Node that doesnt have a mesh and is not a group.
continue # Node that doesn't have a mesh and is not a group.
if only_selectable and not node.isSelectable():
continue # Only remove nodes that are selectable.
if not node.callDecoration("isSliceable") and not node.callDecoration("getLayerData") and not node.callDecoration("isGroup"):
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
nodes.append(node)
if nodes:
from UM.Operations.GroupedOperation import GroupedOperation

View file

@ -12,7 +12,7 @@ from cura.CuraApplication import CuraApplication
# Since Cura has a few pre-defined "space claims" for the locations of certain components, we've provided some structure
# to indicate this.
# MainComponent works in the same way the MainComponent of a stage.
# the stageMenuComponent returns an item that should be used somehwere in the stage menu. It's up to the active stage
# the stageMenuComponent returns an item that should be used somewhere in the stage menu. It's up to the active stage
# to actually do something with this.
class CuraView(View):
def __init__(self, parent = None, use_empty_menu_placeholder: bool = False) -> None:

View file

@ -59,13 +59,13 @@ class LayerPolygon:
self._vertex_count = self._mesh_line_count + numpy.sum(self._types[1:] == self._types[:-1])
# Buffering the colors shouldn't be necessary as it is not
# re-used and can save alot of memory usage.
# re-used and can save a lot of memory usage.
self._color_map = LayerPolygon.getColorMap()
self._colors = self._color_map[self._types] # type: numpy.ndarray
# When type is used as index returns true if type == LayerPolygon.InfillType or type == LayerPolygon.SkinType or type == LayerPolygon.SupportInfillType
# Should be generated in better way, not hardcoded.
self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = numpy.bool)
self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = bool)
self._build_cache_line_mesh_mask = None # type: Optional[numpy.ndarray]
self._build_cache_needed_points = None # type: Optional[numpy.ndarray]
@ -73,18 +73,17 @@ class LayerPolygon:
def buildCache(self) -> None:
# For the line mesh we do not draw Infill or Jumps. Therefore those lines are filtered out.
self._build_cache_line_mesh_mask = numpy.ones(self._jump_mask.shape, dtype = bool)
mesh_line_count = numpy.sum(self._build_cache_line_mesh_mask)
self._index_begin = 0
self._index_end = mesh_line_count
self._index_end = cast(int, numpy.sum(self._build_cache_line_mesh_mask))
self._build_cache_needed_points = numpy.ones((len(self._types), 2), dtype = numpy.bool)
self._build_cache_needed_points = numpy.ones((len(self._types), 2), dtype = bool)
# Only if the type of line segment changes do we need to add an extra vertex to change colors
self._build_cache_needed_points[1:, 0][:, numpy.newaxis] = self._types[1:] != self._types[:-1]
# Mark points as unneeded if they are of types we don't want in the line mesh according to the calculated mask
numpy.logical_and(self._build_cache_needed_points, self._build_cache_line_mesh_mask, self._build_cache_needed_points )
self._vertex_begin = 0
self._vertex_end = numpy.sum( self._build_cache_needed_points )
self._vertex_end = cast(int, numpy.sum(self._build_cache_needed_points))
def build(self, vertex_offset: int, index_offset: int, vertices: numpy.ndarray, colors: numpy.ndarray, line_dimensions: numpy.ndarray, feedrates: numpy.ndarray, extruders: numpy.ndarray, line_types: numpy.ndarray, indices: numpy.ndarray) -> None:
"""Set all the arrays provided by the function caller, representing the LayerPolygon
@ -147,7 +146,7 @@ class LayerPolygon:
# When the line type changes the index needs to be increased by 2.
indices[self._index_begin:self._index_end, :] += numpy.cumsum(needed_points_list[line_mesh_mask.ravel(), 0], dtype = numpy.int32).reshape((-1, 1))
# Each line segment goes from it's starting point p to p+1, offset by the vertex index.
# The -1 is to compensate for the neccecarily True value of needed_points_list[0,0] which causes an unwanted +1 in cumsum above.
# The -1 is to compensate for the necessarily True value of needed_points_list[0,0] which causes an unwanted +1 in cumsum above.
indices[self._index_begin:self._index_end, :] += numpy.array([self._vertex_begin - 1, self._vertex_begin])
self._build_cache_line_mesh_mask = None

View file

@ -97,8 +97,7 @@ class MachineErrorChecker(QObject):
def startErrorCheckPropertyChanged(self, key: str, property_name: str) -> None:
"""Start the error check for property changed
this is seperate from the startErrorCheck because it ignores a number property types
this is separate from the startErrorCheck because it ignores a number property types
:param key:
:param property_name:

View file

@ -53,9 +53,14 @@ class ExtrudersModel(ListModel):
EnabledRole = Qt.UserRole + 11
"""Is the extruder enabled?"""
MaterialTypeRole = Qt.UserRole + 12
"""The type of the material (e.g. PLA, ABS, PETG, etc.)."""
defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"]
"""List of colours to display if there is no material or the material has no known colour. """
MaterialNameRole = Qt.UserRole + 13
def __init__(self, parent = None):
"""Initialises the extruders model, defining the roles and listening for changes in the data.
@ -75,6 +80,8 @@ class ExtrudersModel(ListModel):
self.addRoleName(self.StackRole, "stack")
self.addRoleName(self.MaterialBrandRole, "material_brand")
self.addRoleName(self.ColorNameRole, "color_name")
self.addRoleName(self.MaterialTypeRole, "material_type")
self.addRoleName(self.MaterialNameRole, "material_name")
self._update_extruder_timer = QTimer()
self._update_extruder_timer.setInterval(100)
self._update_extruder_timer.setSingleShot(True)
@ -193,9 +200,10 @@ class ExtrudersModel(ListModel):
"variant": extruder.variant.getName() if extruder.variant else "", # e.g. print core
"stack": extruder,
"material_brand": material_brand,
"color_name": color_name
"color_name": color_name,
"material_type": extruder.material.getMetaDataEntry("material") if extruder.material else "",
"material_name": extruder.material.getMetaDataEntry("name") if extruder.material else "",
}
items.append(item)
extruders_changed = True
@ -210,7 +218,7 @@ class ExtrudersModel(ListModel):
"id": "",
"name": catalog.i18nc("@menuitem", "Not overridden"),
"enabled": True,
"color": "#ffffff",
"color": "transparent",
"index": -1,
"definition": "",
"material": "",
@ -218,6 +226,8 @@ class ExtrudersModel(ListModel):
"stack": None,
"material_brand": "",
"color_name": "",
"material_type": "",
"material_label": ""
}
items.append(item)
if self._items != items:

View file

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import TYPE_CHECKING
@ -34,4 +34,4 @@ def fetchLayerHeight(quality_group: "QualityGroup") -> float:
if isinstance(layer_height, SettingFunction):
layer_height = layer_height(global_stack)
return float(layer_height)
return round(float(layer_height), 3)

View file

@ -1,13 +1,17 @@
# Copyright (c) 2019 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import copy # To duplicate materials.
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot # To allow the preference page proxy to be used from the actual preferences page.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl
from typing import Any, Dict, Optional, TYPE_CHECKING
import uuid # To generate new GUIDs for new materials.
import zipfile # To export all materials in a .zip archive.
from PyQt5.QtGui import QDesktopServices
from UM.i18n import i18nCatalog
from UM.Logger import Logger
from UM.Message import Message
from UM.Signal import postponeSignals, CompressTechnique
import cura.CuraApplication # Imported like this to prevent circular imports.
@ -19,18 +23,71 @@ if TYPE_CHECKING:
catalog = i18nCatalog("cura")
class MaterialManagementModel(QObject):
"""Proxy class to the materials page in the preferences.
This class handles the actions in that page, such as creating new materials, renaming them, etc.
"""
favoritesChanged = pyqtSignal(str)
"""Triggered when a favorite is added or removed.
:param The base file of the material is provided as parameter when this emits
"""
def __init__(self, parent: Optional[QObject] = None) -> None:
super().__init__(parent = parent)
self._checkIfNewMaterialsWereInstalled()
def _checkIfNewMaterialsWereInstalled(self) -> None:
"""
Checks whether new material packages were installed in the latest startup. If there were, then it shows
a message prompting the user to sync the materials with their printers.
"""
application = cura.CuraApplication.CuraApplication.getInstance()
for package_id, package_data in application.getPackageManager().getPackagesInstalledOnStartup().items():
if package_data["package_info"]["package_type"] == "material":
# At least one new material was installed
self._showSyncNewMaterialsMessage()
break
def _showSyncNewMaterialsMessage(self) -> None:
sync_materials_message = Message(
text = catalog.i18nc("@action:button",
"Please sync the material profiles with your printers before starting to print."),
title = catalog.i18nc("@action:button", "New materials installed"),
message_type = Message.MessageType.WARNING,
lifetime = 0
)
sync_materials_message.addAction(
"sync",
name = catalog.i18nc("@action:button", "Sync materials with printers"),
icon = "",
description = "Sync your newly installed materials with your printers.",
button_align = Message.ActionButtonAlignment.ALIGN_RIGHT
)
sync_materials_message.addAction(
"learn_more",
name = catalog.i18nc("@action:button", "Learn more"),
icon = "",
description = "Learn more about syncing your newly installed materials with your printers.",
button_align = Message.ActionButtonAlignment.ALIGN_LEFT,
button_style = Message.ActionButtonStyle.LINK
)
sync_materials_message.actionTriggered.connect(self._onSyncMaterialsMessageActionTriggered)
# Show the message only if there are printers that support material export
container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
global_stacks = container_registry.findContainerStacks(type = "machine")
if any([stack.supportsMaterialExport for stack in global_stacks]):
sync_materials_message.show()
def _onSyncMaterialsMessageActionTriggered(self, sync_message: Message, sync_message_action: str):
if sync_message_action == "sync":
QDesktopServices.openUrl(QUrl("https://example.com/openSyncAllWindow"))
# self.openSyncAllWindow()
sync_message.hide()
elif sync_message_action == "learn_more":
QDesktopServices.openUrl(QUrl("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-printer-message"))
@pyqtSlot("QVariant", result = bool)
def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool:
"""Can a certain material be deleted, or is it still in use in one of the container stacks anywhere?
@ -79,6 +136,7 @@ class MaterialManagementModel(QObject):
:param material_node: The material to remove.
"""
Logger.info(f"Removing material {material_node.container_id}")
container_registry = CuraContainerRegistry.getInstance()
materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file)
@ -194,6 +252,7 @@ class MaterialManagementModel(QObject):
:return: The root material ID of the duplicate material.
"""
Logger.info(f"Duplicating material {material_node.base_file} to {new_base_id}")
return self.duplicateMaterialByBaseFile(material_node.base_file, new_base_id, new_metadata)
@pyqtSlot(result = str)
@ -262,3 +321,53 @@ class MaterialManagementModel(QObject):
self.favoritesChanged.emit(material_base_file)
except ValueError: # Material was not in the favorites list.
Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file))
@pyqtSlot(result = QUrl)
def getPreferredExportAllPath(self) -> QUrl:
"""
Get the preferred path to export materials to.
If there is a removable drive, that should be the preferred path. Otherwise it should be the most recent local
file path.
:return: The preferred path to export all materials to.
"""
cura_application = cura.CuraApplication.CuraApplication.getInstance()
device_manager = cura_application.getOutputDeviceManager()
devices = device_manager.getOutputDevices()
for device in devices:
if device.__class__.__name__ == "RemovableDriveOutputDevice":
return QUrl.fromLocalFile(device.getId())
else: # No removable drives? Use local path.
return cura_application.getDefaultPath("dialog_material_path")
@pyqtSlot(QUrl)
def exportAll(self, file_path: QUrl) -> None:
"""
Export all materials to a certain file path.
:param file_path: The path to export the materials to.
"""
registry = CuraContainerRegistry.getInstance()
try:
archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED)
except OSError as e:
Logger.log("e", f"Can't write to destination {file_path.toLocalFile()}: {type(e)} - {str(e)}")
error_message = Message(
text = catalog.i18nc("@message:text", "Could not save material archive to {}:").format(file_path.toLocalFile()) + " " + str(e),
title = catalog.i18nc("@message:title", "Failed to save material archive"),
message_type = Message.MessageType.ERROR
)
error_message.show()
return
for metadata in registry.findInstanceContainersMetadata(type = "material"):
if metadata["base_file"] != metadata["id"]: # Only process base files.
continue
if metadata["id"] == "empty_material": # Don't export the empty material.
continue
material = registry.findContainers(id = metadata["id"])[0]
suffix = registry.getMimeTypeForContainer(type(material)).preferredSuffix
filename = metadata["id"] + "." + suffix
try:
archive.writestr(filename, material.serialize())
except OSError as e:
Logger.log("e", f"An error has occurred while writing the material \'{metadata['id']}\' in the file \'{filename}\': {e}.")

View file

@ -41,10 +41,6 @@ class QualityProfilesDropDownMenuModel(ListModel):
machine_manager.activeQualityGroupChanged.connect(self._onChange)
machine_manager.activeMaterialChanged.connect(self._onChange)
machine_manager.activeVariantChanged.connect(self._onChange)
machine_manager.extruderChanged.connect(self._onChange)
extruder_manager = application.getExtruderManager()
extruder_manager.extrudersChanged.connect(self._onChange)
self._layer_height_unit = "" # This is cached

View file

@ -99,7 +99,7 @@ class QualitySettingsModel(ListModel):
if self._selected_position == self.GLOBAL_STACK_POSITION:
quality_node = quality_group.node_for_global
else:
quality_node = quality_group.nodes_for_extruders.get(str(self._selected_position))
quality_node = quality_group.nodes_for_extruders.get(self._selected_position)
settings_keys = quality_group.getAllKeys()
quality_containers = []
if quality_node is not None and quality_node.container is not None:
@ -114,10 +114,13 @@ class QualitySettingsModel(ListModel):
global_container = None if len(global_containers) == 0 else global_containers[0]
extruders_containers = {pos: container_registry.findContainers(id = quality_changes_group.metadata_per_extruder[pos]["id"]) for pos in quality_changes_group.metadata_per_extruder}
extruders_container = {pos: None if not containers else containers[0] for pos, containers in extruders_containers.items()}
quality_changes_metadata = None
if self._selected_position == self.GLOBAL_STACK_POSITION and global_container:
quality_changes_metadata = global_container.getMetaData()
else:
quality_changes_metadata = extruders_container.get(str(self._selected_position))
extruder = extruders_container.get(self._selected_position)
if extruder:
quality_changes_metadata = extruder.getMetaData()
if quality_changes_metadata is not None: # It can be None if number of extruders are changed during runtime.
container = container_registry.findContainers(id = quality_changes_metadata["id"])
if container:

View file

@ -19,6 +19,8 @@ class SettingVisibilityPresetsModel(QObject):
onItemsChanged = pyqtSignal()
activePresetChanged = pyqtSignal()
Version = 2
def __init__(self, preferences: Preferences, parent = None) -> None:
super().__init__(parent)
@ -31,7 +33,7 @@ class SettingVisibilityPresetsModel(QObject):
if basic_item is not None:
basic_visibile_settings = ";".join(basic_item.settings)
else:
Logger.log("w", "Unable to find the basic visiblity preset.")
Logger.log("w", "Unable to find the basic visibility preset.")
basic_visibile_settings = ""
self._preferences = preferences

View file

@ -6,11 +6,15 @@ from typing import List
from UM.Application import Application
from UM.Job import Job
from UM.Math.Vector import Vector
from UM.Message import Message
from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
from UM.Operations.GroupedOperation import GroupedOperation
from UM.Operations.TranslateOperation import TranslateOperation
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Scene.SceneNode import SceneNode
from UM.i18n import i18nCatalog
from cura.Arranging.Nest2DArrange import arrange
from cura.Arranging.Nest2DArrange import arrange, createGroupOperationForArrange
i18n_catalog = i18nCatalog("cura")
@ -43,11 +47,11 @@ class MultiplyObjectsJob(Job):
# Only count sliceable objects
if node_.callDecoration("isSliceable"):
fixed_nodes.append(node_)
nodes_to_add_without_arrange = []
for node in self._objects:
# If object is part of a group, multiply group
current_node = node
while current_node.getParent() and (current_node.getParent().callDecoration("isGroup") or current_node.getParent().callDecoration("isSliceable")):
while current_node.getParent() and current_node.getParent().callDecoration("isGroup"):
current_node = current_node.getParent()
if current_node in processed_nodes:
@ -56,23 +60,43 @@ class MultiplyObjectsJob(Job):
for _ in range(self._count):
new_node = copy.deepcopy(node)
# Same build plate
build_plate_number = current_node.callDecoration("getBuildPlateNumber")
new_node.callDecoration("setBuildPlateNumber", build_plate_number)
for child in new_node.getChildren():
child.callDecoration("setBuildPlateNumber", build_plate_number)
nodes.append(new_node)
if not current_node.getParent().callDecoration("isSliceable"):
nodes.append(new_node)
else:
# The node we're trying to place has another node that is sliceable as a parent.
# As such, we shouldn't arrange it (but it should be added to the scene!)
nodes_to_add_without_arrange.append(new_node)
new_node.setParent(current_node.getParent())
found_solution_for_all = True
group_operation = GroupedOperation()
if nodes:
found_solution_for_all = arrange(nodes, Application.getInstance().getBuildVolume(), fixed_nodes,
factor = 10000, add_new_nodes_in_scene = True)
group_operation, not_fit_count = createGroupOperationForArrange(nodes,
Application.getInstance().getBuildVolume(),
fixed_nodes,
factor = 10000,
add_new_nodes_in_scene = True)
found_solution_for_all = not_fit_count == 0
if nodes_to_add_without_arrange:
for nested_node in nodes_to_add_without_arrange:
group_operation.addOperation(AddSceneNodeOperation(nested_node, nested_node.getParent()))
# Move the node a tiny bit so it doesn't overlap with the existing one.
# This doesn't fix it if someone creates more than one duplicate, but it at least shows that something
# happened (and after moving it, it's clear that there are more underneath)
group_operation.addOperation(TranslateOperation(nested_node, Vector(2.5, 2.5, 2.5)))
group_operation.push()
status_message.hide()
if not found_solution_for_all:
no_full_solution_message = Message(
i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"),
title = i18n_catalog.i18nc("@info:title", "Placing Object"))
title = i18n_catalog.i18nc("@info:title", "Placing Object"),
message_type = Message.MessageType.WARNING)
no_full_solution_message.show()

View file

@ -1,12 +1,12 @@
# Copyright (c) 2020 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime
import json
import random
import secrets
from hashlib import sha512
from base64 import b64encode
from typing import Optional, Any, Dict, Tuple
from typing import Optional
import requests
from UM.i18n import i18nCatalog
@ -48,8 +48,8 @@ class AuthorizationHelpers:
}
try:
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore
except requests.exceptions.ConnectionError:
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server")
except requests.exceptions.ConnectionError as connection_error:
return AuthenticationResponse(success = False, err_message = f"Unable to connect to remote server: {connection_error}")
def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse":
"""Request the access token from the authorization server using a refresh token.
@ -115,7 +115,7 @@ class AuthorizationHelpers:
token_request = requests.get(check_token_url, headers = {
"Authorization": "Bearer {}".format(access_token)
})
except requests.exceptions.ConnectionError:
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
# Connection was suddenly dropped. Nothing we can do about that.
Logger.logException("w", "Something failed while attempting to parse the JWT token")
return None
@ -139,11 +139,11 @@ class AuthorizationHelpers:
def generateVerificationCode(code_length: int = 32) -> str:
"""Generate a verification code of arbitrary length.
:param code_length:: How long should the code be? This should never be lower than 16, but it's probably
:param code_length:: How long should the code be in bytes? This should never be lower than 16, but it's probably
better to leave it at 32
"""
return "".join(random.choice("0123456789ABCDEF") for i in range(code_length))
return secrets.token_hex(code_length)
@staticmethod
def generateVerificationCodeChallenge(verification_code: str) -> str:

View file

@ -24,7 +24,7 @@ if TYPE_CHECKING:
from cura.OAuth2.Models import UserProfile, OAuth2Settings
from UM.Preferences import Preferences
MYCLOUD_LOGOFF_URL = "https://mycloud.ultimaker.com/logoff"
MYCLOUD_LOGOFF_URL = "https://account.ultimaker.com/logoff?utm_source=cura&utm_medium=software&utm_campaign=change-account-before-adding-printers"
class AuthorizationService:
"""The authorization service is responsible for handling the login flow, storing user credentials and providing
@ -99,7 +99,14 @@ class AuthorizationService:
# If no auth data exists, we should always log in again.
Logger.log("d", "There was no auth data or access token")
return None
user_data = self._auth_helpers.parseJWT(self._auth_data.access_token)
try:
user_data = self._auth_helpers.parseJWT(self._auth_data.access_token)
except AttributeError:
# THis might seem a bit double, but we get crash reports about this (CURA-2N2 in sentry)
Logger.log("d", "There was no auth data or access token")
return None
if user_data:
# If the profile was found, we return it immediately.
return user_data
@ -113,12 +120,14 @@ class AuthorizationService:
# The token could not be refreshed using the refresh token. We should login again.
return None
# Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been deleted
# from the server already.
self._storeAuthData(self._auth_data)
# from the server already. Do not store the auth_data if we could not get new auth_data (eg due to a
# network error), since this would cause an infinite loop trying to get new auth-data
if self._auth_data.success:
self._storeAuthData(self._auth_data)
return self._auth_helpers.parseJWT(self._auth_data.access_token)
def getAccessToken(self) -> Optional[str]:
"""Get the access token as provided by the repsonse data."""
"""Get the access token as provided by the response data."""
if self._auth_data is None:
Logger.log("d", "No auth data to retrieve the access_token from")
@ -184,8 +193,10 @@ class AuthorizationService:
self._server.start(verification_code, state)
except OSError:
Logger.logException("w", "Unable to create authorization request server")
Message(i18n_catalog.i18nc("@info", "Unable to start a new sign in process. Check if another sign in attempt is still active."),
title=i18n_catalog.i18nc("@info:title", "Warning")).show()
Message(i18n_catalog.i18nc("@info",
"Unable to start a new sign in process. Check if another sign in attempt is still active."),
title=i18n_catalog.i18nc("@info:title", "Warning"),
message_type = Message.MessageType.WARNING).show()
return
auth_url = self._generate_auth_url(query_parameters_dict, force_browser_logout)
@ -205,25 +216,27 @@ class AuthorizationService:
link to force the a browser logout from mycloud.ultimaker.com
:return: The authentication URL, properly formatted and encoded
"""
auth_url = "{}?{}".format(self._auth_url, urlencode(query_parameters_dict))
auth_url = f"{self._auth_url}?{urlencode(query_parameters_dict)}"
if force_browser_logout:
# The url after '?next=' should be urlencoded
auth_url = "{}?next={}".format(MYCLOUD_LOGOFF_URL, quote_plus(auth_url))
connecting_char = "&" if "?" in MYCLOUD_LOGOFF_URL else "?"
# The url after 'next=' should be urlencoded
auth_url = f"{MYCLOUD_LOGOFF_URL}{connecting_char}next={quote_plus(auth_url)}"
return auth_url
def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:
"""Callback method for the authentication flow."""
if auth_response.success:
Logger.log("d", "Got callback from Authorization state. The user should now be logged in!")
self._storeAuthData(auth_response)
self.onAuthStateChanged.emit(logged_in = True)
else:
Logger.log("d", "Got callback from Authorization state. Something went wrong: [%s]", auth_response.err_message)
self.onAuthenticationError.emit(logged_in = False, error_message = auth_response.err_message)
self._server.stop() # Stop the web server at all times.
def loadAuthDataFromPreferences(self) -> None:
"""Load authentication data from preferences."""
Logger.log("d", "Attempting to load the auth data from preferences.")
if self._preferences is None:
Logger.log("e", "Unable to load authentication data, since no preference has been set!")
return
@ -235,11 +248,16 @@ class AuthorizationService:
user_profile = self.getUserProfile()
if user_profile is not None:
self.onAuthStateChanged.emit(logged_in = True)
Logger.log("d", "Auth data was successfully loaded")
else:
if self._unable_to_get_data_message is not None:
self._unable_to_get_data_message.hide()
self._unable_to_get_data_message = Message(i18n_catalog.i18nc("@info", "Unable to reach the Ultimaker account server."), title = i18n_catalog.i18nc("@info:title", "Warning"))
self._unable_to_get_data_message = Message(i18n_catalog.i18nc("@info",
"Unable to reach the Ultimaker account server."),
title = i18n_catalog.i18nc("@info:title", "Warning"),
message_type = Message.MessageType.ERROR)
Logger.log("w", "Unable to load auth data from preferences")
self._unable_to_get_data_message.show()
except (ValueError, TypeError):
Logger.logException("w", "Could not load auth data from preferences")
@ -255,10 +273,10 @@ class AuthorizationService:
self._auth_data = auth_data
if auth_data:
self._user_profile = self.getUserProfile()
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data)))
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(auth_data.dump()))
else:
Logger.log("d", "Clearing the user profile")
self._user_profile = None
self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)
self.accessTokenChanged.emit()

View file

@ -0,0 +1,92 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Type, TYPE_CHECKING, Optional, List
import keyring
from keyring.backend import KeyringBackend
from keyring.errors import NoKeyringError, PasswordSetError, KeyringLocked
from UM.Logger import Logger
if TYPE_CHECKING:
from cura.OAuth2.Models import BaseModel
# Need to do some extra workarounds on windows:
import sys
from UM.Platform import Platform
if Platform.isWindows():
if hasattr(sys, "frozen"):
import win32timezone
from keyring.backends.Windows import WinVaultKeyring
keyring.set_keyring(WinVaultKeyring())
if Platform.isOSX():
from keyring.backends.macOS import Keyring
keyring.set_keyring(Keyring())
if Platform.isLinux():
# We do not support the keyring on Linux, so make sure no Keyring backend is loaded, even if there is a system one.
from keyring.backends.fail import Keyring as NoKeyringBackend
keyring.set_keyring(NoKeyringBackend())
# Even if errors happen, we don't want this stored locally:
DONT_EVER_STORE_LOCALLY: List[str] = ["refresh_token"]
class KeyringAttribute:
"""
Descriptor for attributes that need to be stored in the keyring. With Fallback behaviour to the preference cfg file
"""
def __get__(self, instance: "BaseModel", owner: type) -> Optional[str]:
if self._store_secure: # type: ignore
try:
value = keyring.get_password("cura", self._keyring_name)
return value if value != "" else None
except NoKeyringError:
self._store_secure = False
Logger.logException("w", "No keyring backend present")
return getattr(instance, self._name)
except KeyringLocked:
self._store_secure = False
Logger.log("i", "Access to the keyring was denied.")
return getattr(instance, self._name)
except UnicodeDecodeError:
self._store_secure = False
Logger.log("w", "The password retrieved from the keyring cannot be used because it contains characters that cannot be decoded.")
return getattr(instance, self._name)
else:
return getattr(instance, self._name)
def __set__(self, instance: "BaseModel", value: Optional[str]):
if self._store_secure:
setattr(instance, self._name, None)
if value is not None:
try:
keyring.set_password("cura", self._keyring_name, value)
except (PasswordSetError, KeyringLocked):
self._store_secure = False
if self._name not in DONT_EVER_STORE_LOCALLY:
setattr(instance, self._name, value)
Logger.logException("w", "Keyring access denied")
except NoKeyringError:
self._store_secure = False
if self._name not in DONT_EVER_STORE_LOCALLY:
setattr(instance, self._name, value)
Logger.logException("w", "No keyring backend present")
except BaseException as e:
# A BaseException can occur in Windows when the keyring attempts to write a token longer than 1024
# characters in the Windows Credentials Manager.
self._store_secure = False
if self._name not in DONT_EVER_STORE_LOCALLY:
setattr(instance, self._name, value)
Logger.log("w", "Keyring failed: {}".format(e))
else:
setattr(instance, self._name, value)
def __set_name__(self, owner: type, name: str):
self._name = "_{}".format(name)
self._keyring_name = name
self._store_secure = False
try:
self._store_secure = KeyringBackend.viable
except NoKeyringError:
Logger.logException("w", "Could not use keyring")
setattr(owner, self._name, None)

View file

@ -54,6 +54,7 @@ class LocalAuthorizationServer:
if self._web_server:
# If the server is already running (because of a previously aborted auth flow), we don't have to start it.
# We still inject the new verification code though.
Logger.log("d", "Auth web server was already running. Updating the verification code")
self._web_server.setVerificationCode(verification_code)
return
@ -85,6 +86,7 @@ class LocalAuthorizationServer:
except OSError:
# OS error can happen if the socket was already closed. We really don't care about that case.
pass
Logger.log("d", "Local oauth2 web server was shut down")
self._web_server = None
self._web_server_thread = None
@ -96,12 +98,13 @@ class LocalAuthorizationServer:
:return: None
"""
Logger.log("d", "Local web server for authorization has started")
if self._web_server:
if sys.platform == "win32":
try:
self._web_server.serve_forever()
except OSError as e:
Logger.warning(str(e))
except OSError:
Logger.logException("w", "An exception happened while serving the auth server")
else:
# Leave the default behavior in non-windows platforms
self._web_server.serve_forever()

View file

@ -1,6 +1,8 @@
# Copyright (c) 2020 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, Dict, Any, List
from typing import Optional, Dict, Any, List, Union
from copy import deepcopy
from cura.OAuth2.KeyringAttribute import KeyringAttribute
class BaseModel:
@ -37,12 +39,29 @@ class AuthenticationResponse(BaseModel):
# Data comes from the token response with success flag and error message added.
success = True # type: bool
token_type = None # type: Optional[str]
access_token = None # type: Optional[str]
refresh_token = None # type: Optional[str]
expires_in = None # type: Optional[str]
scope = None # type: Optional[str]
err_message = None # type: Optional[str]
received_at = None # type: Optional[str]
access_token = KeyringAttribute()
refresh_token = KeyringAttribute()
def __init__(self, **kwargs: Any) -> None:
self.access_token = kwargs.pop("access_token", None)
self.refresh_token = kwargs.pop("refresh_token", None)
super(AuthenticationResponse, self).__init__(**kwargs)
def dump(self) -> Dict[str, Union[bool, Optional[str]]]:
"""
Dumps the dictionary of Authentication attributes. KeyringAttributes are transformed to public attributes
If the keyring was used, these will have a None value, otherwise they will have the secret value
:return: Dictionary of Authentication attributes
"""
dumped = deepcopy(vars(self))
dumped["access_token"] = dumped.pop("_access_token")
dumped["refresh_token"] = dumped.pop("_refresh_token")
return dumped
class ResponseStatus(BaseModel):

View file

@ -79,7 +79,7 @@ class PickingPass(RenderPass):
return -1
distance = output.pixel(px, py) # distance in micron, from in r, g & b channels
distance = (distance & 0x00ffffff) / 1000. # drop the alpha channel and covert to mm
distance = (distance & 0x00ffffff) / 1000. # drop the alpha channel and convert to mm
return distance
def getPickedPosition(self, x: int, y: int) -> Vector:

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 typing import Optional, TYPE_CHECKING, cast, List
@ -74,6 +74,7 @@ class PreviewPass(RenderPass):
self._shader.setUniformValue("u_faceId", -1) # Don't render any selected faces in the preview.
else:
Logger.error("Unable to compile shader program: overhang.shader")
return
if not self._non_printing_shader:
self._non_printing_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader"))

View file

@ -49,7 +49,7 @@ class FirmwareUpdater(QObject):
raise NotImplementedError("_updateFirmware needs to be implemented")
def _cleanupAfterUpdate(self) -> None:
"""Cleanup after a succesful update"""
"""Cleanup after a successful update"""
# Clean up for next attempt.
self._update_firmware_thread = Thread(target=self._updateFirmware, daemon=True, name = "FirmwareUpdateThread")

View file

@ -414,6 +414,6 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
@pyqtProperty(str, constant = True)
def ipAddress(self) -> str:
"""IP adress of this printer"""
"""IP address of this printer"""
return self._address

View file

@ -119,21 +119,23 @@ class CuraSceneNode(SceneNode):
self._aabb = None
if self._mesh_data:
self._aabb = self._mesh_data.getExtents(self.getWorldTransformation(copy = False))
else: # If there is no mesh_data, use a bounding box that encompasses the local (0,0,0)
position = self.getWorldPosition()
self._aabb = AxisAlignedBox(minimum = position, maximum = position)
for child in self.getAllChildren():
if child.callDecoration("isNonPrintingMesh"):
# Non-printing-meshes inside a group should not affect push apart or drop to build plate
continue
if not child.getMeshData():
# Nodes without mesh data should not affect bounding boxes of their parents.
child_bb = child.getBoundingBox()
if child_bb is None or child_bb.minimum == child_bb.maximum:
# Child had a degenerate bounding box, such as an empty group. Don't count it along.
continue
if self._aabb is None:
self._aabb = child.getBoundingBox()
self._aabb = child_bb
else:
self._aabb = self._aabb + child.getBoundingBox()
self._aabb = self._aabb + child_bb
if self._aabb is None: # No children that should be included? Just use your own position then, but it's an invalid AABB.
position = self.getWorldPosition()
self._aabb = AxisAlignedBox(minimum = position, maximum = position)
def __deepcopy__(self, memo: Dict[int, object]) -> "CuraSceneNode":
"""Taken from SceneNode, but replaced SceneNode with CuraSceneNode"""
@ -142,6 +144,7 @@ class CuraSceneNode(SceneNode):
copy.setTransformation(self.getLocalTransformation(copy= False))
copy.setMeshData(self._mesh_data)
copy.setVisible(cast(bool, deepcopy(self._visible, memo)))
copy.source_mime_type = cast(str, deepcopy(self.source_mime_type, memo))
copy._selectable = cast(bool, deepcopy(self._selectable, memo))
copy._name = cast(str, deepcopy(self._name, memo))
for decorator in self._decorators:

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.
import os
@ -23,6 +23,8 @@ from UM.Settings.InstanceContainer import InstanceContainer
import cura.CuraApplication
from cura.Machines.ContainerTree import ContainerTree
from cura.Settings.ExtruderStack import ExtruderStack
from cura.Settings.GlobalStack import GlobalStack
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
@ -241,6 +243,7 @@ class ContainerManager(QObject):
file_url = file_url_or_string.toLocalFile()
else:
file_url = file_url_or_string
Logger.info(f"Importing material from {file_url}")
if not file_url or not os.path.exists(file_url):
return {"status": "error", "message": "Invalid path"}
@ -318,7 +321,7 @@ class ContainerManager(QObject):
stack.qualityChanges = quality_changes
if not quality_changes or container_registry.isReadOnly(quality_changes.getId()):
Logger.log("e", "Could not update quality of a nonexistant or read only quality profile in stack %s", stack.getId())
Logger.log("e", "Could not update quality of a nonexistent or read only quality profile in stack %s", stack.getId())
continue
self._performMerge(quality_changes, stack.getTop())
@ -407,7 +410,7 @@ class ContainerManager(QObject):
container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
for plugin_id, container_type in container_registry.getContainerTypes():
# Ignore default container types since those are not plugins
if container_type in (InstanceContainer, ContainerStack, DefinitionContainer):
if container_type in (InstanceContainer, ContainerStack, DefinitionContainer, GlobalStack, ExtruderStack):
continue
serialize_type = ""

View file

@ -32,6 +32,10 @@ from cura.Machines.ContainerTree import ContainerTree
from cura.ReaderWriters.ProfileReader import NoProfileException, ProfileReader
from UM.i18n import i18nCatalog
from .DatabaseHandlers.IntentDatabaseHandler import IntentDatabaseHandler
from .DatabaseHandlers.QualityDatabaseHandler import QualityDatabaseHandler
from .DatabaseHandlers.VariantDatabaseHandler import VariantDatabaseHandler
catalog = i18nCatalog("cura")
@ -44,6 +48,10 @@ class CuraContainerRegistry(ContainerRegistry):
# is added, we check to see if an extruder stack needs to be added.
self.containerAdded.connect(self._onContainerAdded)
self._database_handlers["variant"] = VariantDatabaseHandler()
self._database_handlers["quality"] = QualityDatabaseHandler()
self._database_handlers["intent"] = IntentDatabaseHandler()
@override(ContainerRegistry)
def addContainer(self, container: ContainerInterface) -> bool:
"""Overridden from ContainerRegistry
@ -141,20 +149,29 @@ class CuraContainerRegistry(ContainerRegistry):
success = profile_writer.write(file_name, container_list)
except Exception as e:
Logger.log("e", "Failed to export profile to %s: %s", file_name, str(e))
m = Message(catalog.i18nc("@info:status Don't translate the XML tags <filename> or <message>!", "Failed to export profile to <filename>{0}</filename>: <message>{1}</message>", file_name, str(e)),
m = Message(catalog.i18nc("@info:status Don't translate the XML tags <filename> or <message>!",
"Failed to export profile to <filename>{0}</filename>: <message>{1}</message>",
file_name, str(e)),
lifetime = 0,
title = catalog.i18nc("@info:title", "Error"))
title = catalog.i18nc("@info:title", "Error"),
message_type = Message.MessageType.ERROR)
m.show()
return False
if not success:
Logger.log("w", "Failed to export profile to %s: Writer plugin reported failure.", file_name)
m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Failed to export profile to <filename>{0}</filename>: Writer plugin reported failure.", file_name),
m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!",
"Failed to export profile to <filename>{0}</filename>: Writer plugin reported failure.",
file_name),
lifetime = 0,
title = catalog.i18nc("@info:title", "Error"))
title = catalog.i18nc("@info:title", "Error"),
message_type = Message.MessageType.ERROR)
m.show()
return False
m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Exported profile to <filename>{0}</filename>", file_name),
title = catalog.i18nc("@info:title", "Export succeeded"))
m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!",
"Exported profile to <filename>{0}</filename>",
file_name),
title = catalog.i18nc("@info:title", "Export succeeded"),
message_type = Message.MessageType.POSITIVE)
m.show()
return True

View file

@ -66,7 +66,7 @@ class CuraStackBuilder:
Logger.logException("e", "Failed to create an extruder stack for position {pos}: {err}".format(pos = position, err = str(e)))
return None
# If given, set the machine_extruder_count when creating the machine, or else the extruderList used bellow will
# If given, set the machine_extruder_count when creating the machine, or else the extruderList used below will
# not return the correct extruder list (since by default, the machine_extruder_count is 1) in machines with
# settable number of extruders.
if machine_extruder_count and 0 <= machine_extruder_count <= len(extruder_dict):

View file

@ -0,0 +1,25 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.Settings.SQLQueryFactory import SQLQueryFactory
from UM.Settings.DatabaseContainerMetadataController import DatabaseMetadataContainerController
from UM.Settings.InstanceContainer import InstanceContainer
class IntentDatabaseHandler(DatabaseMetadataContainerController):
"""The Database handler for Intent containers"""
def __init__(self) -> None:
super().__init__(SQLQueryFactory(table = "intent",
fields = {
"id": "text",
"name": "text",
"quality_type": "text",
"intent_category": "text",
"variant": "text",
"definition": "text",
"material": "text",
"version": "text",
"setting_version": "text"
}))
self._container_type = InstanceContainer

View file

@ -0,0 +1,38 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.Settings.SQLQueryFactory import SQLQueryFactory, metadata_type
from UM.Settings.DatabaseContainerMetadataController import DatabaseMetadataContainerController
from UM.Settings.InstanceContainer import InstanceContainer
class QualityDatabaseHandler(DatabaseMetadataContainerController):
"""The Database handler for Quality containers"""
def __init__(self):
super().__init__(SQLQueryFactory(table = "quality",
fields = {
"id": "text",
"name": "text",
"quality_type": "text",
"material": "text",
"variant": "text",
"global_quality": "bool",
"definition": "text",
"version": "text",
"setting_version": "text"
}))
self._container_type = InstanceContainer
def groomMetadata(self, metadata: metadata_type) -> metadata_type:
"""
Ensures that the metadata is in the order of the field keys and has the right size.
if the metadata doesn't contains a key which is stored in the DB it will add it as
an empty string. Key, value pairs that are not stored in the DB are dropped.
If the `global_quality` isn't set it well default to 'False'
:param metadata: The container metadata
"""
if "global_quality" not in metadata:
metadata["global_quality"] = "False"
return super().groomMetadata(metadata)

View file

@ -0,0 +1,22 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.Settings.SQLQueryFactory import SQLQueryFactory
from UM.Settings.DatabaseContainerMetadataController import DatabaseMetadataContainerController
from UM.Settings.InstanceContainer import InstanceContainer
class VariantDatabaseHandler(DatabaseMetadataContainerController):
"""The Database handler for Variant containers"""
def __init__(self):
super().__init__(SQLQueryFactory(table = "variant",
fields = {
"id": "text",
"name": "text",
"hardware_type": "text",
"definition": "text",
"version": "text",
"setting_version": "text"
}))
self._container_type = InstanceContainer

View file

@ -86,6 +86,14 @@ class GlobalStack(CuraContainerStack):
def supportsNetworkConnection(self):
return self.getMetaDataEntry("supports_network_connection", False)
@pyqtProperty(bool, constant = True)
def supportsMaterialExport(self):
"""
Whether the printer supports Cura's export format of material profiles.
:return: ``True`` if it supports it, or ``False`` if not.
"""
return self.getMetaDataEntry("supports_material_export", False)
@classmethod
def getLoadingPriority(cls) -> int:
return 2

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.
import time
@ -627,7 +627,7 @@ class MachineManager(QObject):
return ""
return global_container_stack.getIntentCategory()
# Provies a list of extruder positions that have a different intent from the active one.
# Provides a list of extruder positions that have a different intent from the active one.
@pyqtProperty("QStringList", notify=activeIntentChanged)
def extruderPositionsWithNonActiveIntent(self):
global_container_stack = self._application.getGlobalContainerStack()
@ -853,7 +853,8 @@ class MachineManager(QObject):
self._global_container_stack.userChanges.setProperty(setting_key, "value", self._default_extruder_position)
if add_user_changes:
caution_message = Message(
catalog.i18nc("@info:message Followed by a list of settings.", "Settings have been changed to match the current availability of extruders:") + " [{settings_list}]".format(settings_list = ", ".join(add_user_changes)),
catalog.i18nc("@info:message Followed by a list of settings.",
"Settings have been changed to match the current availability of extruders:") + " [{settings_list}]".format(settings_list = ", ".join(add_user_changes)),
lifetime = 0,
title = catalog.i18nc("@info:title", "Settings updated"))
caution_message.show()
@ -1397,6 +1398,8 @@ class MachineManager(QObject):
# previous one).
self._global_container_stack.setUserChanges(global_user_changes)
for i, user_changes in enumerate(per_extruder_user_changes):
if i >= len(self._global_container_stack.extruderList): # New printer has fewer extruders.
break
self._global_container_stack.extruderList[i].setUserChanges(per_extruder_user_changes[i])
@pyqtSlot(QObject)

View file

@ -18,6 +18,8 @@ class SingleInstance:
self._single_instance_server = None
self._application.getPreferences().addPreference("cura/single_instance_clear_before_load", True)
# Starts a client that checks for a single instance server and sends the files that need to opened if the server
# exists. Returns True if the single instance server is found, otherwise False.
def startClient(self) -> bool:
@ -42,8 +44,9 @@ class SingleInstance:
# "command" field is required and holds the name of the command to execute.
# Other fields depend on the command.
payload = {"command": "clear-all"}
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii"))
if self._application.getPreferences().getValue("cura/single_instance_clear_before_load"):
payload = {"command": "clear-all"}
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii"))
payload = {"command": "focus"}
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii"))
@ -68,7 +71,7 @@ class SingleInstance:
Logger.log("e", "Single instance server was not created.")
def _onClientConnected(self) -> None:
Logger.log("i", "New connection recevied on our single-instance server")
Logger.log("i", "New connection received on our single-instance server")
connection = None #type: Optional[QLocalSocket]
if self._single_instance_server:
connection = self._single_instance_server.nextPendingConnection()

View file

@ -25,8 +25,8 @@ class Snapshot:
pixels = numpy.frombuffer(pixel_array, dtype=numpy.uint8).reshape([height, width, 4])
# Find indices of non zero pixels
nonzero_pixels = numpy.nonzero(pixels)
min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1)
max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1)
min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1) # type: ignore
max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1) # type: ignore
return min_x, max_x, min_y, max_y
@ -93,7 +93,7 @@ class Snapshot:
pixel_output = preview_pass.getOutput()
try:
min_x, max_x, min_y, max_y = Snapshot.getImageBoundaries(pixel_output)
except ValueError:
except (ValueError, AttributeError):
return None
size = max((max_x - min_x) / render_width, (max_y - min_y) / render_height)

View file

@ -56,8 +56,8 @@ class OnExitCallbackManager:
self._application.callLater(self._application.closeApplication)
# This is the callback function which an on-exit callback should call when it finishes, it should provide the
# "should_proceed" flag indicating whether this check has "passed", or in other words, whether quiting the
# application should be blocked. If the last on-exit callback doesn't block the quiting, it will call the next
# "should_proceed" flag indicating whether this check has "passed", or in other words, whether quitting the
# application should be blocked. If the last on-exit callback doesn't block the quitting, it will call the next
# registered on-exit callback if available.
def onCurrentCallbackFinished(self, should_proceed: bool = True) -> None:
if not should_proceed:

View file

@ -90,7 +90,7 @@ class ObjectsModel(ListModel):
parent = node.getParent()
if parent and parent.callDecoration("isGroup"):
return False # Grouped nodes don't need resetting as their parent (the group) is resetted)
return False # Grouped nodes don't need resetting as their parent (the group) is reset)
node_build_plate_number = node.callDecoration("getBuildPlateNumber")
if Application.getInstance().getPreferences().getValue("view/filter_current_build_plate") and node_build_plate_number != self._build_plate_number:
@ -132,9 +132,26 @@ class ObjectsModel(ListModel):
is_group = bool(node.callDecoration("isGroup"))
name_handled_as_group = False
force_rename = False
if not is_group:
# Handle names for individual nodes
if is_group:
# Handle names for grouped nodes
original_name = self._group_name_prefix
current_name = node.getName()
if current_name.startswith(self._group_name_prefix):
# This group has a standard group name, but we may need to renumber it
name_index = int(current_name.split("#")[-1])
name_handled_as_group = True
elif not current_name:
# Force rename this group because this node has not been named as a group yet, probably because
# it's a newly created group.
name_index = 0
force_rename = True
name_handled_as_group = True
if not is_group or not name_handled_as_group:
# Handle names for individual nodes or groups that already have a non-group name
name = node.getName()
name_match = self._naming_regex.fullmatch(name)
@ -144,18 +161,6 @@ class ObjectsModel(ListModel):
else:
original_name = name_match.groups()[0]
name_index = int(name_match.groups()[1])
else:
# Handle names for grouped nodes
original_name = self._group_name_prefix
current_name = node.getName()
if current_name.startswith(self._group_name_prefix):
name_index = int(current_name.split("#")[-1])
else:
# Force rename this group because this node has not been named as a group yet, probably because
# it's a newly created group.
name_index = 0
force_rename = True
if original_name not in name_to_node_info_dict:
# Keep track of 2 things:

View file

@ -13,6 +13,8 @@ from UM.Qt.Duration import Duration
from UM.Scene.SceneNode import SceneNode
from UM.i18n import i18nCatalog
from UM.MimeTypeDatabase import MimeTypeDatabase, MimeTypeNotFoundError
from UM.OutputDevice.OutputDevice import OutputDevice
from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
@ -68,6 +70,7 @@ class PrintInformation(QObject):
self._application.globalContainerStackChanged.connect(self.setToZeroPrintInformation)
self._application.fileLoaded.connect(self.setBaseName)
self._application.workspaceLoaded.connect(self.setProjectName)
self._application.getOutputDeviceManager().writeStarted.connect(self._onOutputStart)
self._application.getMachineManager().rootMaterialChanged.connect(self._onActiveMaterialsChanged)
self._application.getInstance().getPreferences().preferenceChanged.connect(self._onPreferencesChanged)
@ -439,3 +442,11 @@ class PrintInformation(QObject):
"""Listen to scene changes to check if we need to reset the print information"""
self.setToZeroPrintInformation(self._active_build_plate)
def _onOutputStart(self, output_device: OutputDevice) -> None:
"""If this is the sort of output 'device' (like local or online file storage, rather than a printer),
the user could have altered the file-name, and thus the project name should be altered as well."""
if isinstance(output_device, ProjectOutputDevice):
new_name = output_device.getLastOutputName()
if new_name is not None:
self.setJobName(os.path.splitext(os.path.basename(new_name))[0])

View file

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import collections
@ -6,9 +6,11 @@ from typing import Optional, Dict, List, cast
from PyQt5.QtCore import QObject, pyqtSlot
from UM.i18n import i18nCatalog
from UM.Resources import Resources
from UM.Version import Version
catalog = i18nCatalog("cura")
#
# This manager provides means to load texts to QML.
@ -30,30 +32,33 @@ class TextManager(QObject):
# Load change log texts and organize them with a dict
try:
file_path = Resources.getPath(Resources.Texts, "change_log.txt")
except FileNotFoundError:
except FileNotFoundError as e:
# I have no idea how / when this happens, but we're getting crash reports about it.
return ""
return catalog.i18nc("@text:window", "The release notes could not be opened.") + "<br>" + str(e)
change_logs_dict = {} # type: Dict[Version, Dict[str, List[str]]]
with open(file_path, "r", encoding = "utf-8") as f:
open_version = None # type: Optional[Version]
open_header = "" # Initialise to an empty header in case there is no "*" in the first line of the changelog
for line in f:
line = line.replace("\n", "")
if "[" in line and "]" in line:
line = line.replace("[", "")
line = line.replace("]", "")
open_version = Version(line)
if open_version > Version([14, 99, 99]): # Bit of a hack: We released the 15.x.x versions before 2.x
open_version = Version([0, open_version.getMinor(), open_version.getRevision(), open_version.getPostfixVersion()])
open_header = ""
change_logs_dict[open_version] = collections.OrderedDict()
elif line.startswith("*"):
open_header = line.replace("*", "")
change_logs_dict[cast(Version, open_version)][open_header] = []
elif line != "":
if open_header not in change_logs_dict[cast(Version, open_version)]:
try:
with open(file_path, "r", encoding = "utf-8") as f:
open_version = None # type: Optional[Version]
open_header = "" # Initialise to an empty header in case there is no "*" in the first line of the changelog
for line in f:
line = line.replace("\n", "")
if "[" in line and "]" in line:
line = line.replace("[", "")
line = line.replace("]", "")
open_version = Version(line)
if open_version > Version([14, 99, 99]): # Bit of a hack: We released the 15.x.x versions before 2.x
open_version = Version([0, open_version.getMinor(), open_version.getRevision(), open_version.getPostfixVersion()])
open_header = ""
change_logs_dict[open_version] = collections.OrderedDict()
elif line.startswith("*"):
open_header = line.replace("*", "")
change_logs_dict[cast(Version, open_version)][open_header] = []
change_logs_dict[cast(Version, open_version)][open_header].append(line)
elif line != "":
if open_header not in change_logs_dict[cast(Version, open_version)]:
change_logs_dict[cast(Version, open_version)][open_header] = []
change_logs_dict[cast(Version, open_version)][open_header].append(line)
except EnvironmentError as e:
return catalog.i18nc("@text:window", "The release notes could not be opened.") + "<br>" + str(e)
# Format changelog text
content = ""

View file

@ -29,7 +29,7 @@ class WhatsNewPagesModel(WelcomePagesModel):
for filename in files:
basename = os.path.basename(filename)
base, ext = os.path.splitext(basename)
if ext not in include or not base.isdigit():
if ext.lower() not in include or not base.isdigit():
continue
page_no = int(base)
highest = max(highest, page_no)

View file

@ -16,14 +16,6 @@ import argparse
import faulthandler
import os
# 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 PyQt5.QtNetwork import QSslConfiguration, QSslSocket
from UM.Platform import Platform

View file

@ -7,7 +7,7 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
PROJECT_DIR="$( cd "${SCRIPT_DIR}/.." && pwd )"
# Make sure that environment variables are set properly
source /opt/rh/devtoolset-7/enable
source /opt/rh/devtoolset-8/enable
export PATH="${CURA_BUILD_ENV_PATH}/bin:${PATH}"
export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}"
@ -50,7 +50,7 @@ do
echo "Found Uranium branch [${URANIUM_BRANCH}]."
break
else
echo "Could not find Uranium banch [${URANIUM_BRANCH}], try next."
echo "Could not find Uranium branch [${URANIUM_BRANCH}], try next."
fi
done

View file

@ -8,7 +8,7 @@ The build volume draws a cube (for rectangular build plates) that represents the
The build volume also draws a grid underneath the build volume. The grid features 1cm lines which allows the user to roughly estimate how big its print is or the distance between prints. It also features a finer 1mm line pattern within that grid. The grid is drawn as a single quad. This quad is then sent to the graphical card with a specialised shader which draws the grid pattern.
For elliptical build plates, the volume bounds are drawn as two circles, one at the top and one at the bottom of the available height. The build plate grid is drawn as a tesselated circle, but with the same shader.
For elliptical build plates, the volume bounds are drawn as two circles, one at the top and one at the bottom of the available height. The build plate grid is drawn as a tessellated circle, but with the same shader.
Disallowed areas
----

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")
@ -535,7 +540,8 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
"Project file <filename>{0}</filename> contains an unknown machine type"
" <message>{1}</message>. Cannot import the machine."
" Models will be imported instead.", file_name, machine_definition_id),
title = i18n_catalog.i18nc("@info:title", "Open Project File"))
title = i18n_catalog.i18nc("@info:title", "Open Project File"),
message_type = Message.MessageType.WARNING)
message.show()
Logger.log("i", "Could unknown machine definition %s in project file %s, cannot import it.",
@ -632,14 +638,16 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
except EnvironmentError as e:
message = Message(i18n_catalog.i18nc("@info:error Don't translate the XML tags <filename> or <message>!",
"Project file <filename>{0}</filename> is suddenly inaccessible: <message>{1}</message>.", file_name, str(e)),
title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"))
title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"),
message_type = Message.MessageType.ERROR)
message.show()
self.setWorkspaceName("")
return [], {}
except zipfile.BadZipFile as e:
message = Message(i18n_catalog.i18nc("@info:error Don't translate the XML tags <filename> or <message>!",
"Project file <filename>{0}</filename> is corrupt: <message>{1}</message>.", file_name, str(e)),
title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"))
title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"),
message_type = Message.MessageType.ERROR)
message.show()
self.setWorkspaceName("")
return [], {}
@ -691,7 +699,8 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
if not global_stacks:
message = Message(i18n_catalog.i18nc("@info:error Don't translate the XML tag <filename>!",
"Project file <filename>{0}</filename> is made using profiles that"
" are unknown to this version of Ultimaker Cura.", file_name))
" are unknown to this version of Ultimaker Cura.", file_name),
message_type = Message.MessageType.ERROR)
message.show()
self.setWorkspaceName("")
return [], {}
@ -1157,7 +1166,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
@ -44,7 +43,9 @@ class CreateBackupJob(Job):
"""After the job completes, an empty string indicates success. Othrerwise, the value is a translated message."""
def run(self) -> None:
upload_message = Message(catalog.i18nc("@info:backup_status", "Creating your backup..."), title = self.MESSAGE_TITLE, progress = -1)
upload_message = Message(catalog.i18nc("@info:backup_status", "Creating your backup..."),
title = self.MESSAGE_TITLE,
progress = -1)
upload_message.show()
CuraApplication.getInstance().processEvents()
cura_api = CuraApplication.getInstance().getCuraAPI()
@ -99,13 +100,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,17 +109,22 @@ 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:
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
self.backupIdBeingRestored = ""
Message(error_message,
title = catalog.i18nc("@info:title", "Backup"),
message_type = Message.MessageType.ERROR).show()
def _onCreatingStateChanged(self, is_creating: bool = False, error_message: str = None) -> None:
self._is_creating_backup = is_creating
self.creatingStateChanged.emit()
if error_message:
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
Message(error_message,
title = catalog.i18nc("@info:title", "Backup"),
message_type = Message.MessageType.ERROR).show()
else:
self._storeBackupDate()
if not is_creating and not error_message:
@ -152,6 +166,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 +181,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,19 @@ 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"),
message_type = Message.MessageType.ERROR
)
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)
@ -379,7 +392,9 @@ class CuraEngineBackend(QObject, Backend):
if job.getResult() == StartJobResult.MaterialIncompatible:
if application.platformActivity:
self._error_message = Message(catalog.i18nc("@info:status",
"Unable to slice with the current material as it is incompatible with the selected machine or configuration."), title = catalog.i18nc("@info:title", "Unable to slice"))
"Unable to slice with the current material as it is incompatible with the selected machine or configuration."),
title = catalog.i18nc("@info:title", "Unable to slice"),
message_type = Message.MessageType.WARNING)
self._error_message.show()
self.setState(BackendState.Error)
self.backendError.emit(job)
@ -409,8 +424,11 @@ class CuraEngineBackend(QObject, Backend):
continue
error_labels.add(definitions[0].label)
self._error_message = Message(catalog.i18nc("@info:status", "Unable to slice with the current settings. The following settings have errors: {0}").format(", ".join(error_labels)),
title = catalog.i18nc("@info:title", "Unable to slice"))
self._error_message = Message(catalog.i18nc("@info:status",
"Unable to slice with the current settings. The following settings have errors: {0}").format(", ".join(error_labels)),
title = catalog.i18nc("@info:title", "Unable to slice"),
message_type = Message.MessageType.WARNING)
Logger.warning(f"Unable to slice with the current settings. The following settings have errors: {', '.join(error_labels)}")
self._error_message.show()
self.setState(BackendState.Error)
self.backendError.emit(job)
@ -433,8 +451,11 @@ class CuraEngineBackend(QObject, Backend):
Logger.log("e", "When checking settings for errors, unable to find definition for key {key} in per-object stack.".format(key = key))
continue
errors[key] = definition[0].label
self._error_message = Message(catalog.i18nc("@info:status", "Unable to slice due to some per-model settings. The following settings have errors on one or more models: {error_labels}").format(error_labels = ", ".join(errors.values())),
title = catalog.i18nc("@info:title", "Unable to slice"))
self._error_message = Message(catalog.i18nc("@info:status",
"Unable to slice due to some per-model settings. The following settings have errors on one or more models: {error_labels}").format(error_labels = ", ".join(errors.values())),
title = catalog.i18nc("@info:title", "Unable to slice"),
message_type = Message.MessageType.WARNING)
Logger.warning(f"Unable to slice due to per-object settings. The following settings have errors on one or more models: {', '.join(errors.values())}")
self._error_message.show()
self.setState(BackendState.Error)
self.backendError.emit(job)
@ -442,17 +463,22 @@ class CuraEngineBackend(QObject, Backend):
if job.getResult() == StartJobResult.BuildPlateError:
if application.platformActivity:
self._error_message = Message(catalog.i18nc("@info:status", "Unable to slice because the prime tower or prime position(s) are invalid."),
title = catalog.i18nc("@info:title", "Unable to slice"))
self._error_message = Message(catalog.i18nc("@info:status",
"Unable to slice because the prime tower or prime position(s) are invalid."),
title = catalog.i18nc("@info:title", "Unable to slice"),
message_type = Message.MessageType.WARNING)
self._error_message.show()
self.setState(BackendState.Error)
self.backendError.emit(job)
return
else:
self.setState(BackendState.NotStarted)
if job.getResult() == StartJobResult.ObjectsWithDisabledExtruder:
self._error_message = Message(catalog.i18nc("@info:status", "Unable to slice because there are objects associated with disabled Extruder %s.") % job.getMessage(),
title = catalog.i18nc("@info:title", "Unable to slice"))
self._error_message = Message(catalog.i18nc("@info:status",
"Unable to slice because there are objects associated with disabled Extruder %s.") % job.getMessage(),
title = catalog.i18nc("@info:title", "Unable to slice"),
message_type = Message.MessageType.WARNING)
self._error_message.show()
self.setState(BackendState.Error)
self.backendError.emit(job)
@ -464,7 +490,8 @@ class CuraEngineBackend(QObject, Backend):
"\n- Fit within the build volume"
"\n- Are assigned to an enabled extruder"
"\n- Are not all set as modifier meshes"),
title = catalog.i18nc("@info:title", "Unable to slice"))
title = catalog.i18nc("@info:title", "Unable to slice"),
message_type = Message.MessageType.WARNING)
self._error_message.show()
self.setState(BackendState.Error)
self.backendError.emit(job)
@ -622,7 +649,7 @@ class CuraEngineBackend(QObject, Backend):
for node in DepthFirstIterator(self._scene.getRoot()):
if node.callDecoration("getLayerData"):
if not build_plate_numbers or node.callDecoration("getBuildPlateNumber") in build_plate_numbers:
# We can asume that all nodes have a parent as we're looping through the scene (and filter out root)
# We can assume that all nodes have a parent as we're looping through the scene (and filter out root)
cast(SceneNode, node.getParent()).removeChild(node)
def markSliceAll(self) -> None:
@ -922,9 +949,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

@ -257,10 +257,10 @@ class ProcessSlicedLayersJob(Job):
if self.isRunning():
if Application.getInstance().getController().getActiveView().getPluginId() == "SimulationView":
if not self._progress_message:
self._progress_message = Message(catalog.i18nc("@info:status", "Processing Layers"), 0, False, 0, catalog.i18nc("@info:title", "Information"))
self._progress_message = Message(catalog.i18nc("@info:status", "Processing Layers"), 0, False, 0,
catalog.i18nc("@info:title", "Information"))
if self._progress_message.getProgress() != 100:
self._progress_message.show()
else:
if self._progress_message:
self._progress_message.hide()

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.
import numpy
@ -195,7 +195,7 @@ class StartSliceJob(Job):
# Remove old layer data.
for node in DepthFirstIterator(self._scene.getRoot()):
if node.callDecoration("getLayerData") and node.callDecoration("getBuildPlateNumber") == self._build_plate_number:
# Singe we walk through all nodes in the scene, they always have a parent.
# Since we walk through all nodes in the scene, they always have a parent.
cast(SceneNode, node.getParent()).removeChild(node)
break
@ -353,10 +353,19 @@ class StartSliceJob(Job):
result[key] = stack.getProperty(key, "value")
Job.yieldThread()
result["print_bed_temperature"] = result["material_bed_temperature"] # Renamed settings.
# Material identification in addition to non-human-readable GUID
result["material_id"] = stack.material.getMetaDataEntry("base_file", "")
result["material_type"] = stack.material.getMetaDataEntry("material", "")
result["material_name"] = stack.material.getMetaDataEntry("name", "")
result["material_brand"] = stack.material.getMetaDataEntry("brand", "")
# Renamed settings.
result["print_bed_temperature"] = result["material_bed_temperature"]
result["print_temperature"] = result["material_print_temperature"]
result["travel_speed"] = result["speed_travel"]
result["time"] = time.strftime("%H:%M:%S") #Some extra settings.
#Some extra settings.
result["time"] = time.strftime("%H:%M:%S")
result["date"] = time.strftime("%d-%m-%Y")
result["day"] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][int(time.strftime("%w"))]
result["initial_extruder_nr"] = CuraApplication.getInstance().getExtruderManager().getInitialExtruderNr()
@ -455,9 +464,9 @@ class StartSliceJob(Job):
bed_temperature_settings = ["material_bed_temperature", "material_bed_temperature_layer_0"]
pattern = r"\{(%s)(,\s?\w+)?\}" % "|".join(bed_temperature_settings) # match {setting} as well as {setting, extruder_nr}
settings["material_bed_temp_prepend"] = re.search(pattern, start_gcode) == None
print_temperature_settings = ["material_print_temperature", "material_print_temperature_layer_0", "default_material_print_temperature", "material_initial_print_temperature", "material_final_print_temperature", "material_standby_temperature"]
print_temperature_settings = ["material_print_temperature", "material_print_temperature_layer_0", "default_material_print_temperature", "material_initial_print_temperature", "material_final_print_temperature", "material_standby_temperature", "print_temperature"]
pattern = r"\{(%s)(,\s?\w+)?\}" % "|".join(print_temperature_settings) # match {setting} as well as {setting, extruder_nr}
settings["material_print_temp_prepend"] = re.search(pattern, start_gcode) == None
settings["material_print_temp_prepend"] = re.search(pattern, start_gcode) is None
# Replace the setting tokens in start and end g-code.
# Use values from the first used extruder by default so we get the expected temperatures

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.1.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -1,7 +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 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<path style="fill:#000E1A;" d="M39,14V8H27v6h-6V8H9v6H7c-1.7,0-3,1.3-3,3v20c0,1.7,1.3,3,3,3h34c1.7,0,3-1.3,3-3V17
c0-1.7-1.3-3-3-3H39z M29,10h8v4c-2.1,0-4.9,0-8,0V10z M11,10h8v4c-3,0-5.8,0-8,0V10z M42,38H6V16h36V38z"/>
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>

Before

Width:  |  Height:  |  Size: 577 B

After

Width:  |  Height:  |  Size: 470 B

Before After
Before After

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 && dfFilenameTextfield.state !== 'invalid'
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,246 @@
// 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
leftPadding: searchIcon.width + UM.Theme.getSize("default_margin").width * 2
onTextEdited: manager.projectFilter = text //Update the search filter when editing this text field.
placeholderText: "Search"
UM.RecolorImage
{
id: searchIcon
anchors
{
verticalCenter: parent.verticalCenter
left: parent.left
leftMargin: UM.Theme.getSize("default_margin").width
}
source: UM.Theme.getIcon("search")
height: UM.Theme.getSize("small_button_icon").height
width: height
color: UM.Theme.getColor("text")
}
}
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("external_link")
visible: createNewProjectButtonVisible && !manager.userAccountCanCreateNewLibraryProject && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
tooltip: "Maximum number of projects reached. Please upgrade your subscription to create more projects."
onClicked: Qt.openUrlExternally("https://ultimaker.com/software/enterprise-software?utm_source=cura&utm_medium=software&utm_campaign=MaxProjLink")
}
}
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?utm_source=cura&utm_medium=software&utm_campaign=empty-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,17 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
from cura.CuraApplication import CuraApplication
from UM.Message import Message
from UM.Version import Version
def getBackwardsCompatibleMessage(text: str, title: str, message_type_str: str, lifetime: Optional[int] = 30) -> Message:
if CuraApplication.getInstance().getAPIVersion() < Version("7.7.0"):
return Message(text=text, title=title, lifetime=lifetime)
else:
message_type = Message.MessageType.NEUTRAL
if ("MessageType." + message_type_str) in [str(item) for item in Message.MessageType]:
message_type = Message.MessageType[message_type_str]
return Message(text=text, title=title, lifetime=lifetime, message_type=message_type)

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,381 @@
# 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 .BackwardsCompatibleMessage import getBackwardsCompatibleMessage
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 = getBackwardsCompatibleMessage(
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 = 30,
message_type_str = "POSITIVE"
)
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"] = getBackwardsCompatibleMessage(
text = "Failed to export the file '{}'. The upload process is aborted.".format(filename),
title = "Export error",
message_type_str = "ERROR",
lifetime = 30
)
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"] = getBackwardsCompatibleMessage(
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_3mf, self._library_project_name, human_readable_error),
title = "File upload error",
message_type_str = "ERROR",
lifetime = 30
)
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"] = getBackwardsCompatibleMessage(
title = "File upload error",
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_ufp, self._library_project_name, human_readable_error),
message_type_str = "ERROR",
lifetime = 30
)
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"] = getBackwardsCompatibleMessage(
title = "File upload error",
text = "Failed to upload the file '{}' to '{}'. {}".format(self._file_name, self._library_project_name, human_readable_error),
message_type_str = "ERROR",
lifetime = 30
)
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&utm_source=cura&utm_medium=software&utm_campaign=saved-library-file-message".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": getBackwardsCompatibleMessage(
text = "'{}' was uploaded to '{}'.".format(filename_3mf, self._library_project_name),
title = "Upload successful",
message_type_str = "POSITIVE",
lifetime = 30
),
"file_upload_failed_message": getBackwardsCompatibleMessage(
text = "Failed to upload the file '{}' to '{}'.".format(filename_3mf, self._library_project_name),
title = "File upload error",
message_type_str = "ERROR",
lifetime = 30
)
}
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": getBackwardsCompatibleMessage(
text = "'{}' was uploaded to '{}'.".format(filename_ufp, self._library_project_name),
title = "Upload successful",
message_type_str = "POSITIVE",
lifetime = 30,
),
"file_upload_failed_message": getBackwardsCompatibleMessage(
text = "Failed to upload the file '{}' to '{}'.".format(filename_ufp, self._library_project_name),
title = "File upload error",
message_type_str = "ERROR",
lifetime = 30
)
}
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()

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