Merge branch 'master' into Rigid3D

This commit is contained in:
Mehmet Sutaş 2022-03-09 20:22:20 +03:00
commit 96c3c48b00
5089 changed files with 173341 additions and 134925 deletions

View file

@ -64,7 +64,7 @@ body:
You can find your log file here: 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` 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` MacOS: `$USER/Library/Application Support/cura/<Cura version>/cura.log`
Ubuntu/Linus: `$USER/.local/share/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 If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
- type: checkboxes - type: checkboxes

8
.gitignore vendored
View file

@ -10,6 +10,8 @@ resources/i18n/en_7S
resources/i18n/x-test resources/i18n/x-test
resources/firmware resources/firmware
resources/materials resources/materials
resources/images/whats_new
resources/texts/whats_new
CuraEngine.exe CuraEngine.exe
LC_MESSAGES LC_MESSAGES
.cache .cache
@ -37,6 +39,7 @@ cura.desktop
#Externally located plug-ins commonly installed by our devs. #Externally located plug-ins commonly installed by our devs.
plugins/cura-big-flame-graph plugins/cura-big-flame-graph
plugins/cura-camera-position
plugins/cura-god-mode-plugin plugins/cura-god-mode-plugin
plugins/cura-siemensnx-plugin plugins/cura-siemensnx-plugin
plugins/CuraBlenderPlugin plugins/CuraBlenderPlugin
@ -56,6 +59,11 @@ plugins/SettingsGuide
plugins/SettingsGuide2 plugins/SettingsGuide2
plugins/SVGToolpathReader plugins/SVGToolpathReader
plugins/X3GWriter plugins/X3GWriter
plugins/CuraFlatPack
plugins/CuraRemoteSupport
plugins/ModelCutter
plugins/PrintProfileCreator
plugins/MultiPrintPlugin
#Build stuff #Build stuff
CMakeCache.txt CMakeCache.txt

View file

@ -7,5 +7,5 @@ license: "LGPL-3.0"
message: "If you use this software, please cite it using these metadata." message: "If you use this software, please cite it using these metadata."
repository-code: "https://github.com/ultimaker/cura/" repository-code: "https://github.com/ultimaker/cura/"
title: "Ultimaker Cura" title: "Ultimaker Cura"
version: "4.10.0" version: "4.12.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) 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. # So we're using the old method here, with FindPythonInterp for now.
find_package(PythonInterp 3 REQUIRED) find_package(PythonInterp 3 REQUIRED)

View file

@ -2,7 +2,7 @@ Cura
==== ====
Ultimaker Cura is a state-of-the-art slicer application to prepare your 3D models for printing with a 3D printer. With hundreds of settings and hundreds of community-managed print profiles, Ultimaker Cura is sure to lead your next project to a success. Ultimaker Cura is a state-of-the-art slicer application to prepare your 3D models for printing with a 3D printer. With hundreds of settings and hundreds of community-managed print profiles, Ultimaker Cura is sure to lead your next project to a success.
![Screenshot](screenshot.png) ![Screenshot](cura-logo.PNG)
Logging Issues Logging Issues
------------ ------------
@ -34,7 +34,7 @@ Build scripts
------------- -------------
Please check out [cura-build](https://github.com/Ultimaker/cura-build) for detailed building instructions. 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.) If you want to build the entire environment from scratch before building Cura as well, [cura-build-environment](https://github.com/Ultimaker/cura-build-environment) might be a starting point before cura-build. (Again, see cura-build for more details.)
Running from Source Running from Source
------------- -------------

View file

@ -4,7 +4,7 @@
include(CTest) include(CTest)
include(CMakeParseArguments) 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. # So we're using the old method here, with FindPythonInterp for now.
find_package(PythonInterp 3 REQUIRED) find_package(PythonInterp 3 REQUIRED)

View file

@ -28,6 +28,6 @@
<image>https://raw.githubusercontent.com/Ultimaker/Cura/master/screenshot.png</image> <image>https://raw.githubusercontent.com/Ultimaker/Cura/master/screenshot.png</image>
</screenshot> </screenshot>
</screenshots> </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> <translation type="gettext">Cura</translation>
</component> </component>

BIN
cura-logo.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

View file

@ -1,15 +1,15 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime
from typing import Optional, Dict, TYPE_CHECKING, Callable
from datetime import datetime
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS
from typing import Any, Optional, Dict, TYPE_CHECKING, Callable
from UM.Logger import Logger from UM.Logger import Logger
from UM.Message import Message from UM.Message import Message
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from cura.OAuth2.AuthorizationService import AuthorizationService from cura.OAuth2.AuthorizationService import AuthorizationService
from cura.OAuth2.Models import OAuth2Settings from cura.OAuth2.Models import OAuth2Settings, UserProfile
from cura.UltimakerCloud import UltimakerCloudConstants from cura.UltimakerCloud import UltimakerCloudConstants
if TYPE_CHECKING: if TYPE_CHECKING:
@ -46,6 +46,12 @@ class Account(QObject):
loginStateChanged = pyqtSignal(bool) loginStateChanged = pyqtSignal(bool)
"""Signal emitted when user logged in or out""" """Signal emitted when user logged in or out"""
userProfileChanged = pyqtSignal()
"""Signal emitted when new account information is available."""
additionalRightsChanged = pyqtSignal("QVariantMap")
"""Signal emitted when a users additional rights change"""
accessTokenChanged = pyqtSignal() accessTokenChanged = pyqtSignal()
syncRequested = pyqtSignal() syncRequested = pyqtSignal()
"""Sync services may connect to this signal to receive sync triggers. """Sync services may connect to this signal to receive sync triggers.
@ -59,7 +65,7 @@ class Account(QObject):
updatePackagesEnabledChanged = pyqtSignal(bool) updatePackagesEnabledChanged = pyqtSignal(bool)
CLIENT_SCOPES = "account.user.read drive.backup.read drive.backup.write packages.download " \ 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 " \ "packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write connect.material.write " \
"library.project.read library.project.write cura.printjob.read cura.printjob.write " \ "library.project.read library.project.write cura.printjob.read cura.printjob.write " \
"cura.mesh.read cura.mesh.write" "cura.mesh.read cura.mesh.write"
@ -68,12 +74,14 @@ class Account(QObject):
self._application = application self._application = application
self._new_cloud_printers_detected = False self._new_cloud_printers_detected = False
self._error_message = None # type: Optional[Message] self._error_message: Optional[Message] = None
self._logged_in = False self._logged_in = False
self._user_profile: Optional[UserProfile] = None
self._additional_rights: Dict[str, Any] = {}
self._sync_state = SyncState.IDLE self._sync_state = SyncState.IDLE
self._manual_sync_enabled = False self._manual_sync_enabled = False
self._update_packages_enabled = False self._update_packages_enabled = False
self._update_packages_action = None # type: Optional[Callable] self._update_packages_action: Optional[Callable] = None
self._last_sync_str = "-" self._last_sync_str = "-"
self._callback_port = 32118 self._callback_port = 32118
@ -99,7 +107,7 @@ class Account(QObject):
self._update_timer.setSingleShot(True) self._update_timer.setSingleShot(True)
self._update_timer.timeout.connect(self.sync) self._update_timer.timeout.connect(self.sync)
self._sync_services = {} # type: Dict[str, int] self._sync_services: Dict[str, int] = {}
"""contains entries "service_name" : SyncState""" """contains entries "service_name" : SyncState"""
def initialize(self) -> None: def initialize(self) -> None:
@ -192,12 +200,17 @@ class Account(QObject):
self._logged_in = logged_in self._logged_in = logged_in
self.loginStateChanged.emit(logged_in) self.loginStateChanged.emit(logged_in)
if logged_in: if logged_in:
self._authorization_service.getUserProfile(self._onProfileChanged)
self._setManualSyncEnabled(False) self._setManualSyncEnabled(False)
self._sync() self._sync()
else: else:
if self._update_timer.isActive(): if self._update_timer.isActive():
self._update_timer.stop() self._update_timer.stop()
def _onProfileChanged(self, profile: Optional[UserProfile]) -> None:
self._user_profile = profile
self.userProfileChanged.emit()
def _sync(self) -> None: def _sync(self) -> None:
"""Signals all sync services to start syncing """Signals all sync services to start syncing
@ -239,32 +252,28 @@ class Account(QObject):
return return
self._authorization_service.startAuthorizationFlow(force_logout_before_login) self._authorization_service.startAuthorizationFlow(force_logout_before_login)
@pyqtProperty(str, notify=loginStateChanged) @pyqtProperty(str, notify = userProfileChanged)
def userName(self): def userName(self):
user_profile = self._authorization_service.getUserProfile() if not self._user_profile:
if not user_profile: return ""
return None return self._user_profile.username
return user_profile.username
@pyqtProperty(str, notify = loginStateChanged) @pyqtProperty(str, notify = userProfileChanged)
def profileImageUrl(self): def profileImageUrl(self):
user_profile = self._authorization_service.getUserProfile() if not self._user_profile:
if not user_profile: return ""
return None return self._user_profile.profile_image_url
return user_profile.profile_image_url
@pyqtProperty(str, notify=accessTokenChanged) @pyqtProperty(str, notify=accessTokenChanged)
def accessToken(self) -> Optional[str]: def accessToken(self) -> Optional[str]:
return self._authorization_service.getAccessToken() return self._authorization_service.getAccessToken()
@pyqtProperty("QVariantMap", notify = loginStateChanged) @pyqtProperty("QVariantMap", notify = userProfileChanged)
def userProfile(self) -> Optional[Dict[str, Optional[str]]]: def userProfile(self) -> Optional[Dict[str, Optional[str]]]:
"""None if no user is logged in otherwise the logged in user as a dict containing containing user_id, username and profile_image_url """ """None if no user is logged in otherwise the logged in user as a dict containing containing user_id, username and profile_image_url """
if not self._user_profile:
user_profile = self._authorization_service.getUserProfile()
if not user_profile:
return None return None
return user_profile.__dict__ return self._user_profile.__dict__
@pyqtProperty(str, notify=lastSyncDateTimeChanged) @pyqtProperty(str, notify=lastSyncDateTimeChanged)
def lastSyncDateTime(self) -> str: def lastSyncDateTime(self) -> str:
@ -301,3 +310,14 @@ class Account(QObject):
return # Nothing to do, user isn't logged in. return # Nothing to do, user isn't logged in.
self._authorization_service.deleteAuthData() 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

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
# --------- # ---------
@ -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 # 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 # 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. # CuraVersion.py.in template.
CuraSDKVersion = "7.6.0" CuraSDKVersion = "7.9.0"
try: try:
from cura.CuraVersion import CuraAppName # type: ignore from cura.CuraVersion import CuraAppName # type: ignore
@ -46,6 +46,10 @@ except ImportError:
# Various convenience flags indicating what kind of Cura build it is. # Various convenience flags indicating what kind of Cura build it is.
__ENTERPRISE_VERSION_TYPE = "enterprise" __ENTERPRISE_VERSION_TYPE = "enterprise"
IsEnterpriseVersion = CuraBuildType.lower() == __ENTERPRISE_VERSION_TYPE IsEnterpriseVersion = CuraBuildType.lower() == __ENTERPRISE_VERSION_TYPE
IsAlternateVersion = CuraBuildType.lower() not in [DEFAULT_CURA_BUILD_TYPE, __ENTERPRISE_VERSION_TYPE]
# NOTE: IsAlternateVersion is to make it possibile to have 'non-numbered' versions, at least as presented to the user.
# (Internally, it'll still have some sort of version-number, but the user is never meant to see it in the GUI).
# Warning: This will also change (some of) the icons/splash-screen to the 'work in progress' alternatives!
try: try:
from cura.CuraVersion import CuraAppDisplayName # type: ignore from cura.CuraVersion import CuraAppDisplayName # type: ignore

View file

@ -40,7 +40,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
machine_width = build_volume.getWidth() machine_width = build_volume.getWidth()
machine_depth = build_volume.getDepth() machine_depth = build_volume.getDepth()
build_plate_bounding_box = Box(machine_width * factor, machine_depth * factor) build_plate_bounding_box = Box(int(machine_width * factor), int(machine_depth * factor))
if fixed_nodes is None: if fixed_nodes is None:
fixed_nodes = [] fixed_nodes = []
@ -91,7 +91,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
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 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(): for point in hull_polygon.getPoints():
converted_points.append(Point(point[0] * factor, point[1] * factor)) converted_points.append(Point(int(point[0] * factor), int(point[1] * factor)))
item = Item(converted_points) item = Item(converted_points)
item.markAsFixedInBin(0) item.markAsFixedInBin(0)
node_items.append(item) node_items.append(item)
@ -110,18 +110,11 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
return found_solution_for_all, node_items 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: def createGroupOperationForArrange(nodes_to_arrange: List["SceneNode"],
""" build_volume: "BuildVolume",
Find placement for a set of scene nodes, and move them by using a single grouped operation. fixed_nodes: Optional[List["SceneNode"]] = None,
:param nodes_to_arrange: The list of nodes that need to be moved. factor = 10000,
:param build_volume: The build volume that we want to place the nodes in. It gets size & disallowed areas from this. add_new_nodes_in_scene: bool = False) -> Tuple[GroupedOperation, int]:
: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
"""
scene_root = Application.getInstance().getController().getScene().getRoot() scene_root = Application.getInstance().getController().getScene().getRoot()
found_solution_for_all, node_items = findNodePlacement(nodes_to_arrange, build_volume, fixed_nodes, factor) found_solution_for_all, node_items = findNodePlacement(nodes_to_arrange, build_volume, fixed_nodes, factor)
@ -143,6 +136,27 @@ def arrange(nodes_to_arrange: List["SceneNode"], build_volume: "BuildVolume", fi
grouped_operation.addOperation( grouped_operation.addOperation(
TranslateOperation(node, Vector(200, node.getWorldPosition().y, -not_fit_count * 20), set_position = True)) TranslateOperation(node, Vector(200, node.getWorldPosition().y, -not_fit_count * 20), set_position = True))
not_fit_count += 1 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

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
import io import io
@ -168,7 +168,10 @@ class Backup:
preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name)) 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)) 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) 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) # Read the preferences from the newly restored configuration (or else the cached Preferences will override the restored ones)
self._application.readPreferencesFromConfiguration() self._application.readPreferencesFromConfiguration()
@ -178,8 +181,7 @@ class Backup:
return extracted return extracted
@staticmethod def _extractArchive(self, archive: "ZipFile", target_path: str) -> bool:
def _extractArchive(archive: "ZipFile", target_path: str) -> bool:
"""Extract the whole archive to the given target path. """Extract the whole archive to the given target path.
:param archive: The archive as ZipFile. :param archive: The archive as ZipFile.
@ -198,11 +200,17 @@ class Backup:
Resources.factoryReset() Resources.factoryReset()
Logger.log("d", "Extracting backup to location: %s", target_path) Logger.log("d", "Extracting backup to location: %s", target_path)
name_list = archive.namelist() name_list = archive.namelist()
ignore_string = re.compile("|".join(self.IGNORED_FILES + self.IGNORED_FOLDERS))
for archive_filename in name_list: for archive_filename in name_list:
if ignore_string.search(archive_filename):
Logger.warning(f"File ({archive_filename}) in archive that doesn't fit current backup policy; ignored.")
continue
try: try:
archive.extract(archive_filename, target_path) archive.extract(archive_filename, target_path)
except (PermissionError, EnvironmentError): except (PermissionError, EnvironmentError):
Logger.logException("e", f"Unable to extract the file {archive_filename} from the backup due to permission or file system errors.") 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() CuraApplication.getInstance().processEvents()
return True return True

View file

@ -6,6 +6,7 @@ import math
from typing import List, Optional, TYPE_CHECKING, Any, Set, cast, Iterable, Dict from typing import List, Optional, TYPE_CHECKING, Any, Set, cast, Iterable, Dict
from UM.Logger import Logger
from UM.Mesh.MeshData import MeshData from UM.Mesh.MeshData import MeshData
from UM.Mesh.MeshBuilder import MeshBuilder from UM.Mesh.MeshBuilder import MeshBuilder
@ -65,12 +66,13 @@ class BuildVolume(SceneNode):
self._height = 0 # type: float self._height = 0 # type: float
self._depth = 0 # type: float self._depth = 0 # type: float
self._shape = "" # type: str self._shape = "" # type: str
self._scale_vector = Vector(1.0, 1.0, 1.0)
self._shader = None self._shader = None
self._origin_mesh = None # type: Optional[MeshData] self._origin_mesh = None # type: Optional[MeshData]
self._origin_line_length = 20 self._origin_line_length = 20
self._origin_line_width = 1.5 self._origin_line_width = 1
self._enabled = False self._enabled = False
self._grid_mesh = None # type: Optional[MeshData] self._grid_mesh = None # type: Optional[MeshData]
@ -289,7 +291,7 @@ class BuildVolume(SceneNode):
# Mark the node as outside build volume if the set extruder is disabled # Mark the node as outside build volume if the set extruder is disabled
extruder_position = node.callDecoration("getActiveExtruderPosition") extruder_position = node.callDecoration("getActiveExtruderPosition")
try: try:
if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled: if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled and not node.callDecoration("isGroup"):
node.setOutsideBuildArea(True) node.setOutsideBuildArea(True)
continue continue
except IndexError: # Happens when the extruder list is too short. We're not done building the printer in memory yet. except IndexError: # Happens when the extruder list is too short. We're not done building the printer in memory yet.
@ -512,6 +514,13 @@ class BuildVolume(SceneNode):
self._disallowed_area_size = max(size, self._disallowed_area_size) self._disallowed_area_size = max(size, self._disallowed_area_size)
return mb.build() return mb.build()
def _updateScaleFactor(self) -> None:
if not self._global_container_stack:
return
scale_xy = 100.0 / max(100.0, self._global_container_stack.getProperty("material_shrinkage_percentage_xy", "value"))
scale_z = 100.0 / max(100.0, self._global_container_stack.getProperty("material_shrinkage_percentage_z" , "value"))
self._scale_vector = Vector(scale_xy, scale_xy, scale_z)
def rebuild(self) -> None: def rebuild(self) -> None:
"""Recalculates the build volume & disallowed areas.""" """Recalculates the build volume & disallowed areas."""
@ -553,9 +562,12 @@ class BuildVolume(SceneNode):
self._error_mesh = self._buildErrorMesh(min_w, max_w, min_h, max_h, min_d, max_d, disallowed_area_height) self._error_mesh = self._buildErrorMesh(min_w, max_w, min_h, max_h, min_d, max_d, disallowed_area_height)
self._updateScaleFactor()
self._volume_aabb = AxisAlignedBox( self._volume_aabb = AxisAlignedBox(
minimum = Vector(min_w, min_h - 1.0, min_d), minimum = Vector(min_w, min_h - 1.0, min_d).scale(self._scale_vector),
maximum = Vector(max_w, max_h - self._raft_thickness - self._extra_z_clearance, max_d)) maximum = Vector(max_w, max_h - self._raft_thickness - self._extra_z_clearance, max_d).scale(self._scale_vector)
)
bed_adhesion_size = self.getEdgeDisallowedSize() bed_adhesion_size = self.getEdgeDisallowedSize()
@ -563,15 +575,15 @@ class BuildVolume(SceneNode):
# This is probably wrong in all other cases. TODO! # This is probably wrong in all other cases. TODO!
# The +1 and -1 is added as there is always a bit of extra room required to work properly. # The +1 and -1 is added as there is always a bit of extra room required to work properly.
scale_to_max_bounds = AxisAlignedBox( scale_to_max_bounds = AxisAlignedBox(
minimum = Vector(min_w + bed_adhesion_size + 1, min_h, min_d + self._disallowed_area_size - bed_adhesion_size + 1), minimum = Vector(min_w + bed_adhesion_size + 1, min_h, min_d + self._disallowed_area_size - bed_adhesion_size + 1).scale(self._scale_vector),
maximum = Vector(max_w - bed_adhesion_size - 1, max_h - self._raft_thickness - self._extra_z_clearance, max_d - self._disallowed_area_size + bed_adhesion_size - 1) maximum = Vector(max_w - bed_adhesion_size - 1, max_h - self._raft_thickness - self._extra_z_clearance, max_d - self._disallowed_area_size + bed_adhesion_size - 1).scale(self._scale_vector)
) )
self._application.getController().getScene()._maximum_bounds = scale_to_max_bounds # type: ignore self._application.getController().getScene()._maximum_bounds = scale_to_max_bounds # type: ignore
self.updateNodeBoundaryCheck() self.updateNodeBoundaryCheck()
def getBoundingBox(self): def getBoundingBox(self) -> Optional[AxisAlignedBox]:
return self._volume_aabb return self._volume_aabb
def getRaftThickness(self) -> float: def getRaftThickness(self) -> float:
@ -589,6 +601,7 @@ class BuildVolume(SceneNode):
if self._adhesion_type == "raft": if self._adhesion_type == "raft":
self._raft_thickness = ( self._raft_thickness = (
self._global_container_stack.getProperty("raft_base_thickness", "value") + self._global_container_stack.getProperty("raft_base_thickness", "value") +
self._global_container_stack.getProperty("raft_interface_layers", "value") *
self._global_container_stack.getProperty("raft_interface_thickness", "value") + self._global_container_stack.getProperty("raft_interface_thickness", "value") +
self._global_container_stack.getProperty("raft_surface_layers", "value") * self._global_container_stack.getProperty("raft_surface_layers", "value") *
self._global_container_stack.getProperty("raft_surface_thickness", "value") + self._global_container_stack.getProperty("raft_surface_thickness", "value") +
@ -632,18 +645,18 @@ class BuildVolume(SceneNode):
for extruder in extruders: for extruder in extruders:
extruder.propertyChanged.connect(self._onSettingPropertyChanged) extruder.propertyChanged.connect(self._onSettingPropertyChanged)
self._width = self._global_container_stack.getProperty("machine_width", "value") self._width = self._global_container_stack.getProperty("machine_width", "value") * self._scale_vector.x
machine_height = self._global_container_stack.getProperty("machine_height", "value") machine_height = self._global_container_stack.getProperty("machine_height", "value")
if self._global_container_stack.getProperty("print_sequence", "value") == "one_at_a_time" and len(self._scene_objects) > 1: if self._global_container_stack.getProperty("print_sequence", "value") == "one_at_a_time" and len(self._scene_objects) > 1:
self._height = min(self._global_container_stack.getProperty("gantry_height", "value"), machine_height) self._height = min(self._global_container_stack.getProperty("gantry_height", "value") * self._scale_vector.z, machine_height)
if self._height < machine_height: if self._height < (machine_height * self._scale_vector.z):
self._build_volume_message.show() self._build_volume_message.show()
else: else:
self._build_volume_message.hide() self._build_volume_message.hide()
else: else:
self._height = self._global_container_stack.getProperty("machine_height", "value") self._height = self._global_container_stack.getProperty("machine_height", "value")
self._build_volume_message.hide() self._build_volume_message.hide()
self._depth = self._global_container_stack.getProperty("machine_depth", "value") self._depth = self._global_container_stack.getProperty("machine_depth", "value") * self._scale_vector.y
self._shape = self._global_container_stack.getProperty("machine_shape", "value") self._shape = self._global_container_stack.getProperty("machine_shape", "value")
self._updateDisallowedAreas() self._updateDisallowedAreas()
@ -677,18 +690,18 @@ class BuildVolume(SceneNode):
if setting_key == "print_sequence": if setting_key == "print_sequence":
machine_height = self._global_container_stack.getProperty("machine_height", "value") machine_height = self._global_container_stack.getProperty("machine_height", "value")
if self._application.getGlobalContainerStack().getProperty("print_sequence", "value") == "one_at_a_time" and len(self._scene_objects) > 1: if self._application.getGlobalContainerStack().getProperty("print_sequence", "value") == "one_at_a_time" and len(self._scene_objects) > 1:
self._height = min(self._global_container_stack.getProperty("gantry_height", "value"), machine_height) self._height = min(self._global_container_stack.getProperty("gantry_height", "value") * self._scale_vector.z, machine_height)
if self._height < machine_height: if self._height < (machine_height * self._scale_vector.z):
self._build_volume_message.show() self._build_volume_message.show()
else: else:
self._build_volume_message.hide() self._build_volume_message.hide()
else: else:
self._height = self._global_container_stack.getProperty("machine_height", "value") self._height = self._global_container_stack.getProperty("machine_height", "value") * self._scale_vector.z
self._build_volume_message.hide() self._build_volume_message.hide()
update_disallowed_areas = True update_disallowed_areas = True
# sometimes the machine size or shape settings are adjusted on the active machine, we should reflect this # sometimes the machine size or shape settings are adjusted on the active machine, we should reflect this
if setting_key in self._machine_settings: if setting_key in self._machine_settings or setting_key in self._material_size_settings:
self._updateMachineSizeProperties() self._updateMachineSizeProperties()
update_extra_z_clearance = True update_extra_z_clearance = True
update_disallowed_areas = True update_disallowed_areas = True
@ -737,9 +750,10 @@ class BuildVolume(SceneNode):
def _updateMachineSizeProperties(self) -> None: def _updateMachineSizeProperties(self) -> None:
if not self._global_container_stack: if not self._global_container_stack:
return return
self._height = self._global_container_stack.getProperty("machine_height", "value") self._updateScaleFactor()
self._width = self._global_container_stack.getProperty("machine_width", "value") self._height = self._global_container_stack.getProperty("machine_height", "value") * self._scale_vector.z
self._depth = self._global_container_stack.getProperty("machine_depth", "value") self._width = self._global_container_stack.getProperty("machine_width", "value") * self._scale_vector.x
self._depth = self._global_container_stack.getProperty("machine_depth", "value") * self._scale_vector.y
self._shape = self._global_container_stack.getProperty("machine_shape", "value") self._shape = self._global_container_stack.getProperty("machine_shape", "value")
def _updateDisallowedAreasAndRebuild(self): def _updateDisallowedAreasAndRebuild(self):
@ -756,6 +770,14 @@ class BuildVolume(SceneNode):
self._extra_z_clearance = self._calculateExtraZClearance(ExtruderManager.getInstance().getUsedExtruderStacks()) self._extra_z_clearance = self._calculateExtraZClearance(ExtruderManager.getInstance().getUsedExtruderStacks())
self.rebuild() self.rebuild()
def _scaleAreas(self, result_areas: List[Polygon]) -> None:
if self._global_container_stack is None:
return
for i, polygon in enumerate(result_areas):
result_areas[i] = polygon.scale(
100.0 / max(100.0, self._global_container_stack.getProperty("material_shrinkage_percentage_xy", "value"))
)
def _updateDisallowedAreas(self) -> None: def _updateDisallowedAreas(self) -> None:
if not self._global_container_stack: if not self._global_container_stack:
return return
@ -811,9 +833,11 @@ class BuildVolume(SceneNode):
self._disallowed_areas = [] self._disallowed_areas = []
for extruder_id in result_areas: for extruder_id in result_areas:
self._scaleAreas(result_areas[extruder_id])
self._disallowed_areas.extend(result_areas[extruder_id]) self._disallowed_areas.extend(result_areas[extruder_id])
self._disallowed_areas_no_brim = [] self._disallowed_areas_no_brim = []
for extruder_id in result_areas_no_brim: for extruder_id in result_areas_no_brim:
self._scaleAreas(result_areas_no_brim[extruder_id])
self._disallowed_areas_no_brim.extend(result_areas_no_brim[extruder_id]) self._disallowed_areas_no_brim.extend(result_areas_no_brim[extruder_id])
def _computeDisallowedAreasPrinted(self, used_extruders): def _computeDisallowedAreasPrinted(self, used_extruders):
@ -825,10 +849,10 @@ class BuildVolume(SceneNode):
""" """
result = {} result = {}
adhesion_extruder = None #type: ExtruderStack skirt_brim_extruder: ExtruderStack = None
for extruder in used_extruders: for extruder in used_extruders:
if int(extruder.getProperty("extruder_nr", "value")) == int(self._global_container_stack.getProperty("adhesion_extruder_nr", "value")): if int(extruder.getProperty("extruder_nr", "value")) == int(self._global_container_stack.getProperty("skirt_brim_extruder_nr", "value")):
adhesion_extruder = extruder skirt_brim_extruder = extruder
result[extruder.getId()] = [] result[extruder.getId()] = []
# Currently, the only normally printed object is the prime tower. # Currently, the only normally printed object is the prime tower.
@ -842,11 +866,11 @@ class BuildVolume(SceneNode):
prime_tower_x = prime_tower_x - machine_width / 2 #Offset by half machine_width and _depth to put the origin in the front-left. prime_tower_x = prime_tower_x - machine_width / 2 #Offset by half machine_width and _depth to put the origin in the front-left.
prime_tower_y = prime_tower_y + machine_depth / 2 prime_tower_y = prime_tower_y + machine_depth / 2
if adhesion_extruder is not None and self._global_container_stack.getProperty("prime_tower_brim_enable", "value") and self._global_container_stack.getProperty("adhesion_type", "value") != "raft": if skirt_brim_extruder is not None and self._global_container_stack.getProperty("prime_tower_brim_enable", "value") and self._global_container_stack.getProperty("adhesion_type", "value") != "raft":
brim_size = ( brim_size = (
adhesion_extruder.getProperty("brim_line_count", "value") * skirt_brim_extruder.getProperty("brim_line_count", "value") *
adhesion_extruder.getProperty("skirt_brim_line_width", "value") / 100.0 * skirt_brim_extruder.getProperty("skirt_brim_line_width", "value") / 100.0 *
adhesion_extruder.getProperty("initial_layer_line_width_factor", "value") skirt_brim_extruder.getProperty("initial_layer_line_width_factor", "value")
) )
prime_tower_x -= brim_size prime_tower_x -= brim_size
prime_tower_y += brim_size prime_tower_y += brim_size
@ -1077,13 +1101,18 @@ class BuildVolume(SceneNode):
# with the adhesion extruder, but it also prints one extra line by all other extruders. As such, the # with the adhesion extruder, but it also prints one extra line by all other extruders. As such, the
# setting does *not* have a limit_to_extruder setting (which means that we can't ask the global extruder what # setting does *not* have a limit_to_extruder setting (which means that we can't ask the global extruder what
# the value is. # the value is.
adhesion_extruder = self._global_container_stack.getProperty("adhesion_extruder_nr", "value") skirt_brim_extruder_nr = self._global_container_stack.getProperty("skirt_brim_extruder_nr", "value")
skirt_brim_line_width = self._global_container_stack.extruderList[int(adhesion_extruder)].getProperty("skirt_brim_line_width", "value") try:
skirt_brim_stack = self._global_container_stack.extruderList[int(skirt_brim_extruder_nr)]
except IndexError:
Logger.warning(f"Couldn't find extruder with index '{skirt_brim_extruder_nr}', defaulting to 0 instead.")
skirt_brim_stack = self._global_container_stack.extruderList[0]
skirt_brim_line_width = skirt_brim_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 = skirt_brim_stack.getProperty("initial_layer_line_width_factor", "value")
# Use brim width if brim is enabled OR the prime tower has a brim. # Use brim width if brim is enabled OR the prime tower has a brim.
if adhesion_type == "brim": if adhesion_type == "brim":
brim_line_count = self._global_container_stack.getProperty("brim_line_count", "value") brim_line_count = skirt_brim_stack.getProperty("brim_line_count", "value")
bed_adhesion_size = skirt_brim_line_width * brim_line_count * initial_layer_line_width_factor / 100.0 bed_adhesion_size = skirt_brim_line_width * brim_line_count * initial_layer_line_width_factor / 100.0
for extruder_stack in used_extruders: for extruder_stack in used_extruders:
@ -1092,8 +1121,8 @@ class BuildVolume(SceneNode):
# We don't create an additional line for the extruder we're printing the brim with. # We don't create an additional line for the extruder we're printing the brim with.
bed_adhesion_size -= skirt_brim_line_width * initial_layer_line_width_factor / 100.0 bed_adhesion_size -= skirt_brim_line_width * initial_layer_line_width_factor / 100.0
elif adhesion_type == "skirt": elif adhesion_type == "skirt":
skirt_distance = self._global_container_stack.getProperty("skirt_gap", "value") skirt_distance = skirt_brim_stack.getProperty("skirt_gap", "value")
skirt_line_count = self._global_container_stack.getProperty("skirt_line_count", "value") skirt_line_count = skirt_brim_stack.getProperty("skirt_line_count", "value")
bed_adhesion_size = skirt_distance + ( bed_adhesion_size = skirt_distance + (
skirt_brim_line_width * skirt_line_count) * initial_layer_line_width_factor / 100.0 skirt_brim_line_width * skirt_line_count) * initial_layer_line_width_factor / 100.0
@ -1104,7 +1133,7 @@ class BuildVolume(SceneNode):
# We don't create an additional line for the extruder we're printing the skirt with. # We don't create an additional line for the extruder we're printing the skirt with.
bed_adhesion_size -= skirt_brim_line_width * initial_layer_line_width_factor / 100.0 bed_adhesion_size -= skirt_brim_line_width * initial_layer_line_width_factor / 100.0
elif adhesion_type == "raft": elif adhesion_type == "raft":
bed_adhesion_size = self._global_container_stack.getProperty("raft_margin", "value") bed_adhesion_size = self._global_container_stack.getProperty("raft_margin", "value") # Should refer to the raft extruder if set.
elif adhesion_type == "none": elif adhesion_type == "none":
bed_adhesion_size = 0 bed_adhesion_size = 0
else: else:
@ -1186,12 +1215,13 @@ class BuildVolume(SceneNode):
_machine_settings = ["machine_width", "machine_depth", "machine_height", "machine_shape", "machine_center_is_zero"] _machine_settings = ["machine_width", "machine_depth", "machine_height", "machine_shape", "machine_center_is_zero"]
_skirt_settings = ["adhesion_type", "skirt_gap", "skirt_line_count", "skirt_brim_line_width", "brim_width", "brim_line_count", "raft_margin", "draft_shield_enabled", "draft_shield_dist", "initial_layer_line_width_factor"] _skirt_settings = ["adhesion_type", "skirt_gap", "skirt_line_count", "skirt_brim_line_width", "brim_width", "brim_line_count", "raft_margin", "draft_shield_enabled", "draft_shield_dist", "initial_layer_line_width_factor"]
_raft_settings = ["adhesion_type", "raft_base_thickness", "raft_interface_thickness", "raft_surface_layers", "raft_surface_thickness", "raft_airgap", "layer_0_z_overlap"] _raft_settings = ["adhesion_type", "raft_base_thickness", "raft_interface_layers", "raft_interface_thickness", "raft_surface_layers", "raft_surface_thickness", "raft_airgap", "layer_0_z_overlap"]
_extra_z_settings = ["retraction_hop_enabled", "retraction_hop"] _extra_z_settings = ["retraction_hop_enabled", "retraction_hop"]
_prime_settings = ["extruder_prime_pos_x", "extruder_prime_pos_y", "prime_blob_enable"] _prime_settings = ["extruder_prime_pos_x", "extruder_prime_pos_y", "prime_blob_enable"]
_tower_settings = ["prime_tower_enable", "prime_tower_size", "prime_tower_position_x", "prime_tower_position_y", "prime_tower_brim_enable"] _tower_settings = ["prime_tower_enable", "prime_tower_size", "prime_tower_position_x", "prime_tower_position_y", "prime_tower_brim_enable"]
_ooze_shield_settings = ["ooze_shield_enabled", "ooze_shield_dist"] _ooze_shield_settings = ["ooze_shield_enabled", "ooze_shield_dist"]
_distance_settings = ["infill_wipe_dist", "travel_avoid_distance", "support_offset", "support_enable", "travel_avoid_other_parts", "travel_avoid_supports", "wall_line_count", "wall_line_width_0", "wall_line_width_x"] _distance_settings = ["infill_wipe_dist", "travel_avoid_distance", "support_offset", "support_enable", "travel_avoid_other_parts", "travel_avoid_supports", "wall_line_count", "wall_line_width_0", "wall_line_width_x"]
_extruder_settings = ["support_enable", "support_bottom_enable", "support_roof_enable", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "brim_line_count", "adhesion_extruder_nr", "adhesion_type"] #Settings that can affect which extruders are used. _extruder_settings = ["support_enable", "support_bottom_enable", "support_roof_enable", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "brim_line_count", "skirt_brim_extruder_nr", "raft_base_extruder_nr", "raft_interface_extruder_nr", "raft_surface_extruder_nr", "adhesion_type"] #Settings that can affect which extruders are used.
_limit_to_extruder_settings = ["wall_extruder_nr", "wall_0_extruder_nr", "wall_x_extruder_nr", "top_bottom_extruder_nr", "infill_extruder_nr", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "adhesion_extruder_nr"] _limit_to_extruder_settings = ["wall_extruder_nr", "wall_0_extruder_nr", "wall_x_extruder_nr", "top_bottom_extruder_nr", "infill_extruder_nr", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "skirt_brim_extruder_nr", "raft_base_extruder_nr", "raft_interface_extruder_nr", "raft_surface_extruder_nr"]
_disallowed_area_settings = _skirt_settings + _prime_settings + _tower_settings + _ooze_shield_settings + _distance_settings + _extruder_settings _material_size_settings = ["material_shrinkage_percentage", "material_shrinkage_percentage_xy", "material_shrinkage_percentage_z"]
_disallowed_area_settings = _skirt_settings + _prime_settings + _tower_settings + _ooze_shield_settings + _distance_settings + _extruder_settings + _material_size_settings

View file

@ -15,7 +15,7 @@ from typing import cast, Any
try: try:
from sentry_sdk.hub import Hub from sentry_sdk.hub import Hub
from sentry_sdk.utils import event_from_exception from sentry_sdk.utils import event_from_exception
from sentry_sdk import configure_scope from sentry_sdk import configure_scope, add_breadcrumb
with_sentry_sdk = True with_sentry_sdk = True
except ImportError: except ImportError:
with_sentry_sdk = False with_sentry_sdk = False
@ -424,6 +424,13 @@ class CrashHandler:
if with_sentry_sdk: if with_sentry_sdk:
try: try:
hub = Hub.current hub = Hub.current
if not Logger.getLoggers():
# No loggers have been loaded yet, so we don't have any breadcrumbs :(
# So add them manually so we at least have some info...
add_breadcrumb(level = "info", message = "SentryLogging was not initialised yet")
for log_type, line in Logger.getUnloggedLines():
add_breadcrumb(message=line)
event, hint = event_from_exception((self.exception_type, self.value, self.traceback)) event, hint = event_from_exception((self.exception_type, self.value, self.traceback))
hub.capture_event(event, hint=hint) hub.capture_event(event, hint=hint)
hub.flush() hub.flush()

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. # 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. # 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. # 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) cura.CuraApplication.CuraApplication.getInstance().functionEvent(event)
@pyqtSlot() @pyqtSlot()

View file

@ -129,7 +129,7 @@ class CuraApplication(QtApplication):
# SettingVersion represents the set of settings available in the machine/extruder definitions. # 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 # You need to make sure that this version number needs to be increased if there is any non-backwards-compatible
# changes of the settings. # changes of the settings.
SettingVersion = 17 SettingVersion = 19
Created = False Created = False
@ -152,16 +152,17 @@ class CuraApplication(QtApplication):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(name = ApplicationMetadata.CuraAppName, super().__init__(name = ApplicationMetadata.CuraAppName,
app_display_name = ApplicationMetadata.CuraAppDisplayName, app_display_name = ApplicationMetadata.CuraAppDisplayName,
version = ApplicationMetadata.CuraVersion, version = ApplicationMetadata.CuraVersion if not ApplicationMetadata.IsAlternateVersion else ApplicationMetadata.CuraBuildType,
api_version = ApplicationMetadata.CuraSDKVersion, api_version = ApplicationMetadata.CuraSDKVersion,
build_type = ApplicationMetadata.CuraBuildType, build_type = ApplicationMetadata.CuraBuildType,
is_debug_mode = ApplicationMetadata.CuraDebugMode, is_debug_mode = ApplicationMetadata.CuraDebugMode,
tray_icon_name = "cura-icon-32.png", tray_icon_name = "cura-icon-32.png" if not ApplicationMetadata.IsAlternateVersion else "cura-icon-32_wip.png",
**kwargs) **kwargs)
self.default_theme = "cura-light" 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.beta_change_log_url = "https://ultimaker.com/ultimaker-cura-beta-features?utm_source=cura&utm_medium=software&utm_campaign=cura-update-features"
self._boot_loading_time = time.time() self._boot_loading_time = time.time()
@ -320,7 +321,7 @@ class CuraApplication(QtApplication):
super().initialize() super().initialize()
self._preferences.addPreference("cura/single_instance", False) 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.__sendCommandToSingleInstance()
self._initializeSettingDefinitions() self._initializeSettingDefinitions()
@ -471,6 +472,8 @@ class CuraApplication(QtApplication):
("definition_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.DefinitionChangesContainer, "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"), ("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"), ("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")
} }
) )
@ -481,7 +484,7 @@ class CuraApplication(QtApplication):
if not self.getIsHeadLess(): if not self.getIsHeadLess():
try: try:
self.setWindowIcon(QIcon(Resources.getPath(Resources.Images, "cura-icon.png"))) self.setWindowIcon(QIcon(Resources.getPath(Resources.Images, "cura-icon.png" if not ApplicationMetadata.IsAlternateVersion else "cura-icon_wip.png")))
except FileNotFoundError: except FileNotFoundError:
Logger.log("w", "Unable to find the window icon.") Logger.log("w", "Unable to find the window icon.")
@ -491,7 +494,7 @@ class CuraApplication(QtApplication):
"CuraEngineBackend", #Cura is useless without this one since you can't slice. "CuraEngineBackend", #Cura is useless without this one since you can't slice.
"FileLogger", #You want to be able to read the log if something goes wrong. "FileLogger", #You want to be able to read the log if something goes wrong.
"XmlMaterialProfile", #Cura crashes without this one. "XmlMaterialProfile", #Cura crashes without this one.
"Toolbox", #This contains the interface to enable/disable plug-ins, so if you disable it you can't enable it back. "Marketplace", #This contains the interface to enable/disable plug-ins, so if you disable it you can't enable it back.
"PrepareStage", #Cura is useless without this one since you can't load models. "PrepareStage", #Cura is useless without this one since you can't load models.
"PreviewStage", #This shows the list of the plugin views that are installed in Cura. "PreviewStage", #This shows the list of the plugin views that are installed in Cura.
"MonitorStage", #Major part of Cura's functionality. "MonitorStage", #Major part of Cura's functionality.
@ -570,6 +573,10 @@ class CuraApplication(QtApplication):
preferences.addPreference("general/accepted_user_agreement", False) preferences.addPreference("general/accepted_user_agreement", False)
preferences.addPreference("cura/market_place_show_plugin_banner", True)
preferences.addPreference("cura/market_place_show_material_banner", True)
preferences.addPreference("cura/market_place_show_manage_packages_banner", True)
for key in [ for key in [
"dialog_load_path", # dialog_save_path is in LocalFileOutputDevicePlugin "dialog_load_path", # dialog_save_path is in LocalFileOutputDevicePlugin
"dialog_profile_path", "dialog_profile_path",
@ -672,22 +679,6 @@ class CuraApplication(QtApplication):
self._setLoadingHint(self._i18n_catalog.i18nc("@info:progress", "Initializing Active Machine...")) self._setLoadingHint(self._i18n_catalog.i18nc("@info:progress", "Initializing Active Machine..."))
super().setGlobalContainerStack(stack) super().setGlobalContainerStack(stack)
showMessageBox = pyqtSignal(str,str, str, str, int, int,
arguments = ["title", "text", "informativeText", "detailedText","buttons", "icon"])
"""A reusable dialogbox"""
def messageBox(self, title, text,
informativeText = "",
detailedText = "",
buttons = QMessageBox.Ok,
icon = QMessageBox.NoIcon,
callback = None,
callback_arguments = []
):
self._message_box_callback = callback
self._message_box_callback_arguments = callback_arguments
self.showMessageBox.emit(title, text, informativeText, detailedText, buttons, icon)
showDiscardOrKeepProfileChanges = pyqtSignal() showDiscardOrKeepProfileChanges = pyqtSignal()
def discardOrKeepProfileChanges(self) -> bool: def discardOrKeepProfileChanges(self) -> bool:
@ -714,6 +705,7 @@ class CuraApplication(QtApplication):
for extruder in global_stack.extruderList: for extruder in global_stack.extruderList:
extruder.userChanges.clear() extruder.userChanges.clear()
global_stack.userChanges.clear() global_stack.userChanges.clear()
self.getMachineManager().correctExtruderSettings()
# if the user decided to keep settings then the user settings should be re-calculated and validated for errors # if the user decided to keep settings then the user settings should be re-calculated and validated for errors
# before slicing. To ensure that slicer uses right settings values # before slicing. To ensure that slicer uses right settings values
@ -748,7 +740,9 @@ class CuraApplication(QtApplication):
@pyqtSlot(str, result = QUrl) @pyqtSlot(str, result = QUrl)
def getDefaultPath(self, key): def getDefaultPath(self, key):
default_path = self.getPreferences().getValue("local_file/%s" % 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) @pyqtSlot(str, str)
def setDefaultPath(self, key, default_path): def setDefaultPath(self, key, default_path):
@ -771,10 +765,14 @@ class CuraApplication(QtApplication):
lib_suffixes = {""} lib_suffixes = {""}
for suffix in lib_suffixes: for suffix in lib_suffixes:
self._plugin_registry.addPluginLocation(os.path.join(QtApplication.getInstallPrefix(), "lib" + suffix, "cura")) self._plugin_registry.addPluginLocation(os.path.join(QtApplication.getInstallPrefix(), "lib" + suffix, "cura"))
if not hasattr(sys, "frozen"): if not hasattr(sys, "frozen"):
self._plugin_registry.addPluginLocation(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "plugins")) self._plugin_registry.addPluginLocation(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "plugins"))
self._plugin_registry.preloaded_plugins.append("ConsoleLogger") self._plugin_registry.preloaded_plugins.append("ConsoleLogger")
# Since it's possible to get crashes in code before the sentrylogger is loaded, we want to start this plugin
# as quickly as possible, as we might get unsolvable crash reports without it.
self._plugin_registry.preloaded_plugins.append("SentryLogger")
self._plugin_registry.loadPlugins() self._plugin_registry.loadPlugins()
if self.getBackend() is None: if self.getBackend() is None:
@ -1310,9 +1308,9 @@ class CuraApplication(QtApplication):
if not isinstance(node, SceneNode): if not isinstance(node, SceneNode):
continue continue
if not node.getMeshData() and not node.callDecoration("isGroup"): 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"): 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(): if not node.isSelectable():
continue # i.e. node with layer data continue # i.e. node with layer data
if not node.callDecoration("isSliceable") and not node.callDecoration("isGroup"): if not node.callDecoration("isSliceable") and not node.callDecoration("isGroup"):
@ -1330,9 +1328,9 @@ class CuraApplication(QtApplication):
if not isinstance(node, SceneNode): if not isinstance(node, SceneNode):
continue continue
if not node.getMeshData() and not node.callDecoration("isGroup"): 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"): 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(): if not node.isSelectable():
continue # i.e. node with layer data continue # i.e. node with layer data
nodes.append(node) nodes.append(node)
@ -1359,9 +1357,9 @@ class CuraApplication(QtApplication):
if not isinstance(node, SceneNode): if not isinstance(node, SceneNode):
continue continue
if not node.getMeshData() and not node.callDecoration("isGroup"): 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"): 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"): if not node.callDecoration("isSliceable") and not node.callDecoration("isGroup"):
continue # i.e. node with layer data continue # i.e. node with layer data
nodes.append(node) nodes.append(node)
@ -1388,7 +1386,7 @@ class CuraApplication(QtApplication):
continue continue
if not node.getMeshData() and not node.callDecoration("isGroup"): 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() parent_node = node.getParent()
if parent_node and parent_node.callDecoration("isGroup"): if parent_node and parent_node.callDecoration("isGroup"):
@ -1416,11 +1414,11 @@ class CuraApplication(QtApplication):
continue continue
if not node.getMeshData() and not node.callDecoration("isGroup"): 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() parent_node = node.getParent()
if parent_node and parent_node.callDecoration("isGroup"): 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(): if not node.isSelectable():
continue # i.e. node with layer data continue # i.e. node with layer data
@ -2037,11 +2035,11 @@ class CuraApplication(QtApplication):
if not node.isEnabled(): if not node.isEnabled():
continue continue
if (not node.getMeshData() and not node.callDecoration("getLayerData")) and not node.callDecoration("isGroup"): 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(): if only_selectable and not node.isSelectable():
continue # Only remove nodes that are selectable. continue # Only remove nodes that are selectable.
if not node.callDecoration("isSliceable") and not node.callDecoration("getLayerData") and not node.callDecoration("isGroup"): 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) nodes.append(node)
if nodes: if nodes:
from UM.Operations.GroupedOperation import GroupedOperation from UM.Operations.GroupedOperation import GroupedOperation

View file

@ -1,13 +1,15 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import List, Tuple, TYPE_CHECKING, Optional from typing import Any, cast, Dict, List, Set, Tuple, TYPE_CHECKING, Optional
from cura.CuraApplication import CuraApplication #To find some resource types. from cura.CuraApplication import CuraApplication # To find some resource types.
from cura.Settings.GlobalStack import GlobalStack from cura.Settings.GlobalStack import GlobalStack
from UM.PackageManager import PackageManager #The class we're extending. from UM.PackageManager import PackageManager # The class we're extending.
from UM.Resources import Resources #To find storage paths for some resource types. from UM.Resources import Resources # To find storage paths for some resource types.
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
if TYPE_CHECKING: if TYPE_CHECKING:
from UM.Qt.QtApplication import QtApplication from UM.Qt.QtApplication import QtApplication
@ -17,6 +19,31 @@ if TYPE_CHECKING:
class CuraPackageManager(PackageManager): class CuraPackageManager(PackageManager):
def __init__(self, application: "QtApplication", parent: Optional["QObject"] = None) -> None: def __init__(self, application: "QtApplication", parent: Optional["QObject"] = None) -> None:
super().__init__(application, parent) super().__init__(application, parent)
self._local_packages: Optional[List[Dict[str, Any]]] = None
self._local_packages_ids: Optional[Set[str]] = None
self.installedPackagesChanged.connect(self._updateLocalPackages)
def _updateLocalPackages(self) -> None:
self._local_packages = self.getAllLocalPackages()
self._local_packages_ids = set(pkg["package_id"] for pkg in self._local_packages)
@property
def local_packages(self) -> List[Dict[str, Any]]:
"""locally installed packages, lazy execution"""
if self._local_packages is None:
self._updateLocalPackages()
# _updateLocalPackages always results in a list of packages, not None.
# It's guaranteed to be a list now.
return cast(List[Dict[str, Any]], self._local_packages)
@property
def local_packages_ids(self) -> Set[str]:
"""locally installed packages, lazy execution"""
if self._local_packages_ids is None:
self._updateLocalPackages()
# _updateLocalPackages always results in a list of packages, not None.
# It's guaranteed to be a list now.
return cast(Set[str], self._local_packages_ids)
def initialize(self) -> None: def initialize(self) -> None:
self._installation_dirs_dict["materials"] = Resources.getStoragePath(CuraApplication.ResourceTypes.MaterialInstanceContainer) self._installation_dirs_dict["materials"] = Resources.getStoragePath(CuraApplication.ResourceTypes.MaterialInstanceContainer)
@ -47,3 +74,12 @@ class CuraPackageManager(PackageManager):
machine_with_qualities.append((global_stack, str(extruder_nr), container_id)) machine_with_qualities.append((global_stack, str(extruder_nr), container_id))
return machine_with_materials, machine_with_qualities return machine_with_materials, machine_with_qualities
def getAllLocalPackages(self) -> List[Dict[str, Any]]:
""" Returns an unordered list of all the package_info of installed, to be installed, or bundled packages"""
packages: List[Dict[str, Any]] = []
for packages_to_add in self.getAllInstalledPackagesInfo().values():
packages.extend(packages_to_add)
return packages

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 # Since Cura has a few pre-defined "space claims" for the locations of certain components, we've provided some structure
# to indicate this. # to indicate this.
# MainComponent works in the same way the MainComponent of a stage. # 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. # to actually do something with this.
class CuraView(View): class CuraView(View):
def __init__(self, parent = None, use_empty_menu_placeholder: bool = False) -> None: def __init__(self, parent = None, use_empty_menu_placeholder: bool = False) -> None:

View file

@ -59,7 +59,7 @@ class LayerPolygon:
self._vertex_count = self._mesh_line_count + numpy.sum(self._types[1:] == self._types[:-1]) 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 # 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._color_map = LayerPolygon.getColorMap()
self._colors = self._color_map[self._types] # type: numpy.ndarray self._colors = self._color_map[self._types] # type: numpy.ndarray
@ -146,7 +146,7 @@ class LayerPolygon:
# When the line type changes the index needs to be increased by 2. # 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)) 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. # 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]) indices[self._index_begin:self._index_end, :] += numpy.array([self._vertex_begin - 1, self._vertex_begin])
self._build_cache_line_mesh_mask = None 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: def startErrorCheckPropertyChanged(self, key: str, property_name: str) -> None:
"""Start the error check for property changed """Start the error check for property changed
this is separate from the startErrorCheck because it ignores a number property types
this is seperate from the startErrorCheck because it ignores a number property types
:param key: :param key:
:param property_name: :param property_name:

View file

@ -59,6 +59,8 @@ class ExtrudersModel(ListModel):
defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"] defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"]
"""List of colours to display if there is no material or the material has no known colour. """ """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): def __init__(self, parent = None):
"""Initialises the extruders model, defining the roles and listening for changes in the data. """Initialises the extruders model, defining the roles and listening for changes in the data.
@ -79,6 +81,7 @@ class ExtrudersModel(ListModel):
self.addRoleName(self.MaterialBrandRole, "material_brand") self.addRoleName(self.MaterialBrandRole, "material_brand")
self.addRoleName(self.ColorNameRole, "color_name") self.addRoleName(self.ColorNameRole, "color_name")
self.addRoleName(self.MaterialTypeRole, "material_type") self.addRoleName(self.MaterialTypeRole, "material_type")
self.addRoleName(self.MaterialNameRole, "material_name")
self._update_extruder_timer = QTimer() self._update_extruder_timer = QTimer()
self._update_extruder_timer.setInterval(100) self._update_extruder_timer.setInterval(100)
self._update_extruder_timer.setSingleShot(True) self._update_extruder_timer.setSingleShot(True)
@ -199,8 +202,8 @@ class ExtrudersModel(ListModel):
"material_brand": material_brand, "material_brand": material_brand,
"color_name": color_name, "color_name": color_name,
"material_type": extruder.material.getMetaDataEntry("material") if extruder.material else "", "material_type": extruder.material.getMetaDataEntry("material") if extruder.material else "",
"material_name": extruder.material.getMetaDataEntry("name") if extruder.material else "",
} }
items.append(item) items.append(item)
extruders_changed = True extruders_changed = True
@ -224,6 +227,7 @@ class ExtrudersModel(ListModel):
"material_brand": "", "material_brand": "",
"color_name": "", "color_name": "",
"material_type": "", "material_type": "",
"material_label": ""
} }
items.append(item) items.append(item)
if self._items != items: if self._items != items:

View file

@ -1,7 +1,8 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt, QTimer from PyQt5.QtCore import Qt, QTimer, pyqtProperty, pyqtSignal
from typing import List, Optional
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
@ -10,6 +11,7 @@ from UM.Util import parseBool
from cura.PrinterOutput.PrinterOutputDevice import ConnectionType from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
from cura.Settings.GlobalStack import GlobalStack from cura.Settings.GlobalStack import GlobalStack
from cura.UltimakerCloud.UltimakerCloudConstants import META_CAPABILITIES # To filter on the printer's capabilities.
class GlobalStacksModel(ListModel): class GlobalStacksModel(ListModel):
@ -20,6 +22,7 @@ class GlobalStacksModel(ListModel):
MetaDataRole = Qt.UserRole + 5 MetaDataRole = Qt.UserRole + 5
DiscoverySourceRole = Qt.UserRole + 6 # For separating local and remote printers in the machine management page DiscoverySourceRole = Qt.UserRole + 6 # For separating local and remote printers in the machine management page
RemovalWarningRole = Qt.UserRole + 7 RemovalWarningRole = Qt.UserRole + 7
IsOnlineRole = Qt.UserRole + 8
def __init__(self, parent = None) -> None: def __init__(self, parent = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -31,18 +34,70 @@ class GlobalStacksModel(ListModel):
self.addRoleName(self.HasRemoteConnectionRole, "hasRemoteConnection") self.addRoleName(self.HasRemoteConnectionRole, "hasRemoteConnection")
self.addRoleName(self.MetaDataRole, "metadata") self.addRoleName(self.MetaDataRole, "metadata")
self.addRoleName(self.DiscoverySourceRole, "discoverySource") self.addRoleName(self.DiscoverySourceRole, "discoverySource")
self.addRoleName(self.IsOnlineRole, "isOnline")
self._change_timer = QTimer() self._change_timer = QTimer()
self._change_timer.setInterval(200) self._change_timer.setInterval(200)
self._change_timer.setSingleShot(True) self._change_timer.setSingleShot(True)
self._change_timer.timeout.connect(self._update) self._change_timer.timeout.connect(self._update)
self._filter_connection_type = None # type: Optional[ConnectionType]
self._filter_online_only = False
self._filter_capabilities: List[str] = [] # Required capabilities that all listed printers must have.
# Listen to changes # Listen to changes
CuraContainerRegistry.getInstance().containerAdded.connect(self._onContainerChanged) CuraContainerRegistry.getInstance().containerAdded.connect(self._onContainerChanged)
CuraContainerRegistry.getInstance().containerMetaDataChanged.connect(self._onContainerChanged) CuraContainerRegistry.getInstance().containerMetaDataChanged.connect(self._onContainerChanged)
CuraContainerRegistry.getInstance().containerRemoved.connect(self._onContainerChanged) CuraContainerRegistry.getInstance().containerRemoved.connect(self._onContainerChanged)
self._updateDelayed() self._updateDelayed()
filterConnectionTypeChanged = pyqtSignal()
filterCapabilitiesChanged = pyqtSignal()
filterOnlineOnlyChanged = pyqtSignal()
def setFilterConnectionType(self, new_filter: Optional[ConnectionType]) -> None:
if self._filter_connection_type != new_filter:
self._filter_connection_type = new_filter
self.filterConnectionTypeChanged.emit()
@pyqtProperty(int, fset = setFilterConnectionType, notify = filterConnectionTypeChanged)
def filterConnectionType(self) -> int:
"""
The connection type to filter the list of printers by.
Only printers that match this connection type will be listed in the
model.
"""
if self._filter_connection_type is None:
return -1
return self._filter_connection_type.value
def setFilterOnlineOnly(self, new_filter: bool) -> None:
if self._filter_online_only != new_filter:
self._filter_online_only = new_filter
self.filterOnlineOnlyChanged.emit()
@pyqtProperty(bool, fset = setFilterOnlineOnly, notify = filterOnlineOnlyChanged)
def filterOnlineOnly(self) -> bool:
"""
Whether to filter the global stacks to show only printers that are online.
"""
return self._filter_online_only
def setFilterCapabilities(self, new_filter: List[str]) -> None:
if self._filter_capabilities != new_filter:
self._filter_capabilities = new_filter
self.filterCapabilitiesChanged.emit()
@pyqtProperty("QStringList", fset = setFilterCapabilities, notify = filterCapabilitiesChanged)
def filterCapabilities(self) -> List[str]:
"""
Capabilities to require on the list of printers.
Only printers that have all of these capabilities will be shown in this model.
"""
return self._filter_capabilities
def _onContainerChanged(self, container) -> None: def _onContainerChanged(self, container) -> None:
"""Handler for container added/removed events from registry""" """Handler for container added/removed events from registry"""
@ -58,6 +113,10 @@ class GlobalStacksModel(ListModel):
container_stacks = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine") container_stacks = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine")
for container_stack in container_stacks: for container_stack in container_stacks:
if self._filter_connection_type is not None: # We want to filter on connection types.
if not any((connection_type == self._filter_connection_type for connection_type in container_stack.configuredConnectionTypes)):
continue # No connection type on this printer matches the filter.
has_remote_connection = False has_remote_connection = False
for connection_type in container_stack.configuredConnectionTypes: for connection_type in container_stack.configuredConnectionTypes:
@ -67,6 +126,14 @@ class GlobalStacksModel(ListModel):
if parseBool(container_stack.getMetaDataEntry("hidden", False)): if parseBool(container_stack.getMetaDataEntry("hidden", False)):
continue continue
is_online = container_stack.getMetaDataEntry("is_online", False)
if self._filter_online_only and not is_online:
continue
capabilities = set(container_stack.getMetaDataEntry(META_CAPABILITIES, "").split(","))
if set(self._filter_capabilities) - capabilities: # Not all required capabilities are met.
continue
device_name = container_stack.getMetaDataEntry("group_name", container_stack.getName()) device_name = container_stack.getMetaDataEntry("group_name", container_stack.getName())
section_name = "Connected printers" if has_remote_connection else "Preset printers" section_name = "Connected printers" if has_remote_connection else "Preset printers"
section_name = self._catalog.i18nc("@info:title", section_name) section_name = self._catalog.i18nc("@info:title", section_name)
@ -82,6 +149,7 @@ class GlobalStacksModel(ListModel):
"hasRemoteConnection": has_remote_connection, "hasRemoteConnection": has_remote_connection,
"metadata": container_stack.getMetaData().copy(), "metadata": container_stack.getMetaData().copy(),
"discoverySource": section_name, "discoverySource": section_name,
"removalWarning": removal_warning}) "removalWarning": removal_warning,
"isOnline": is_online})
items.sort(key=lambda i: (not i["hasRemoteConnection"], i["name"])) items.sort(key=lambda i: (not i["hasRemoteConnection"], i["name"]))
self.setItems(items) self.setItems(items)

View file

@ -106,11 +106,15 @@ class IntentCategoryModel(ListModel):
for category in available_categories: for category in available_categories:
qualities = IntentModel() qualities = IntentModel()
qualities.setIntentCategory(category) qualities.setIntentCategory(category)
try:
weight = list(IntentCategoryModel._get_translations().keys()).index(category)
except ValueError:
weight = 99
result.append({ result.append({
"name": IntentCategoryModel.translation(category, "name", catalog.i18nc("@label", "Unknown")), "name": IntentCategoryModel.translation(category, "name", category),
"description": IntentCategoryModel.translation(category, "description", None), "description": IntentCategoryModel.translation(category, "description", None),
"intent_category": category, "intent_category": category,
"weight": list(IntentCategoryModel._get_translations().keys()).index(category), "weight": weight,
"qualities": qualities "qualities": qualities
}) })
result.sort(key = lambda k: k["weight"]) result.sort(key = lambda k: k["weight"])

View file

@ -2,24 +2,28 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import copy # To duplicate materials. import copy # To duplicate materials.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QUrl
from PyQt5.QtGui import QDesktopServices
from typing import Any, Dict, Optional, TYPE_CHECKING from typing import Any, Dict, Optional, TYPE_CHECKING
import uuid # To generate new GUIDs for new materials. import uuid # To generate new GUIDs for new materials.
import zipfile # To export all materials in a .zip archive.
from UM.Message import Message
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from UM.Logger import Logger from UM.Logger import Logger
from UM.Resources import Resources # To find QML files.
from UM.Signal import postponeSignals, CompressTechnique from UM.Signal import postponeSignals, CompressTechnique
import cura.CuraApplication # Imported like this to prevent circular imports. import cura.CuraApplication # Imported like this to prevent cirmanagecular imports.
from cura.Machines.ContainerTree import ContainerTree from cura.Machines.ContainerTree import ContainerTree
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To find the sets of materials belonging to each other, and currently loaded extruder stacks. from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To find the sets of materials belonging to each other, and currently loaded extruder stacks.
from cura.UltimakerCloud.CloudMaterialSync import CloudMaterialSync
if TYPE_CHECKING: if TYPE_CHECKING:
from cura.Machines.MaterialNode import MaterialNode from cura.Machines.MaterialNode import MaterialNode
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
class MaterialManagementModel(QObject): class MaterialManagementModel(QObject):
favoritesChanged = pyqtSignal(str) favoritesChanged = pyqtSignal(str)
"""Triggered when a favorite is added or removed. """Triggered when a favorite is added or removed.
@ -27,6 +31,66 @@ class MaterialManagementModel(QObject):
:param The base file of the material is provided as parameter when this emits :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._material_sync = CloudMaterialSync(parent=self)
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
# TODO: This should be enabled again once CURA-8609 is merged
#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"),
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) @pyqtSlot("QVariant", result = bool)
def canMaterialBeRemoved(self, material_node: "MaterialNode") -> 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? """Can a certain material be deleted, or is it still in use in one of the container stacks anywhere?
@ -261,39 +325,10 @@ class MaterialManagementModel(QObject):
except ValueError: # Material was not in the favorites list. 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)) Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file))
@pyqtSlot(result = QUrl) @pyqtSlot()
def getPreferredExportAllPath(self) -> QUrl: def openSyncAllWindow(self) -> None:
""" """
Get the preferred path to export materials to. Opens the window to sync all materials.
"""
self._material_sync.openSyncAllWindow()
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()
archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED)
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
archive.writestr(filename, material.serialize())

View file

@ -361,8 +361,15 @@ class QualityManagementModel(ListModel):
"section_name": catalog.i18nc("@label", intent_translations.get(intent_category, {}).get("name", catalog.i18nc("@label", "Unknown"))), "section_name": catalog.i18nc("@label", intent_translations.get(intent_category, {}).get("name", catalog.i18nc("@label", "Unknown"))),
}) })
# Sort by quality_type for each intent category # Sort by quality_type for each intent category
intent_translations_list = list(intent_translations)
result = sorted(result, key = lambda x: (list(intent_translations).index(x["intent_category"]), x["quality_type"])) def getIntentWeight(intent_category):
try:
return intent_translations_list.index(intent_category)
except ValueError:
return 99
result = sorted(result, key = lambda x: (getIntentWeight(x["intent_category"]), x["quality_type"]))
item_list += result item_list += result
# Create quality_changes group items # Create quality_changes group items

View file

@ -41,10 +41,6 @@ class QualityProfilesDropDownMenuModel(ListModel):
machine_manager.activeQualityGroupChanged.connect(self._onChange) machine_manager.activeQualityGroupChanged.connect(self._onChange)
machine_manager.activeMaterialChanged.connect(self._onChange) machine_manager.activeMaterialChanged.connect(self._onChange)
machine_manager.activeVariantChanged.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 self._layer_height_unit = "" # This is cached

View file

@ -1,4 +1,4 @@
# Copyright (c) 2020 Ultimaker B.V. # Copyright (c) 2022 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, Qt from PyQt5.QtCore import pyqtProperty, pyqtSignal, Qt
@ -9,6 +9,7 @@ from UM import i18nCatalog
from UM.Logger import Logger from UM.Logger import Logger
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.Settings.SettingFunction import SettingFunction # To format setting functions differently.
import os import os
@ -173,12 +174,22 @@ class QualitySettingsModel(ListModel):
label = definition.label label = definition.label
if self._i18n_catalog: if self._i18n_catalog:
label = self._i18n_catalog.i18nc(definition.key + " label", label) label = self._i18n_catalog.i18nc(definition.key + " label", label)
if profile_value_source == "quality_changes":
label = f"<i>{label}</i>" # Make setting name italic if it's derived from the quality-changes profile.
if isinstance(profile_value, SettingFunction):
if self._i18n_catalog:
profile_value_display = self._i18n_catalog.i18nc("@info:status", "Calculated")
else:
profile_value_display = "Calculated"
else:
profile_value_display = "" if profile_value is None else str(profile_value)
items.append({ items.append({
"key": definition.key, "key": definition.key,
"label": label, "label": label,
"unit": definition.unit, "unit": definition.unit,
"profile_value": "" if profile_value is None else str(profile_value), # it is for display only "profile_value": profile_value_display,
"profile_value_source": profile_value_source, "profile_value_source": profile_value_source,
"user_value": "" if user_value is None else str(user_value), "user_value": "" if user_value is None else str(user_value),
"category": current_category "category": current_category

View file

@ -33,7 +33,7 @@ class SettingVisibilityPresetsModel(QObject):
if basic_item is not None: if basic_item is not None:
basic_visibile_settings = ";".join(basic_item.settings) basic_visibile_settings = ";".join(basic_item.settings)
else: else:
Logger.log("w", "Unable to find the basic visiblity preset.") Logger.log("w", "Unable to find the basic visibility preset.")
basic_visibile_settings = "" basic_visibile_settings = ""
self._preferences = preferences self._preferences = preferences

View file

@ -6,11 +6,15 @@ from typing import List
from UM.Application import Application from UM.Application import Application
from UM.Job import Job from UM.Job import Job
from UM.Math.Vector import Vector
from UM.Message import Message 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.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from cura.Arranging.Nest2DArrange import arrange from cura.Arranging.Nest2DArrange import arrange, createGroupOperationForArrange
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")
@ -43,11 +47,11 @@ class MultiplyObjectsJob(Job):
# Only count sliceable objects # Only count sliceable objects
if node_.callDecoration("isSliceable"): if node_.callDecoration("isSliceable"):
fixed_nodes.append(node_) fixed_nodes.append(node_)
nodes_to_add_without_arrange = []
for node in self._objects: for node in self._objects:
# If object is part of a group, multiply group # If object is part of a group, multiply group
current_node = node 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() current_node = current_node.getParent()
if current_node in processed_nodes: if current_node in processed_nodes:
@ -56,19 +60,38 @@ class MultiplyObjectsJob(Job):
for _ in range(self._count): for _ in range(self._count):
new_node = copy.deepcopy(node) new_node = copy.deepcopy(node)
# Same build plate # Same build plate
build_plate_number = current_node.callDecoration("getBuildPlateNumber") build_plate_number = current_node.callDecoration("getBuildPlateNumber")
new_node.callDecoration("setBuildPlateNumber", build_plate_number) new_node.callDecoration("setBuildPlateNumber", build_plate_number)
for child in new_node.getChildren(): for child in new_node.getChildren():
child.callDecoration("setBuildPlateNumber", build_plate_number) child.callDecoration("setBuildPlateNumber", build_plate_number)
if not current_node.getParent().callDecoration("isSliceable"):
nodes.append(new_node) 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 found_solution_for_all = True
group_operation = GroupedOperation()
if nodes: if nodes:
found_solution_for_all = arrange(nodes, Application.getInstance().getBuildVolume(), fixed_nodes, group_operation, not_fit_count = createGroupOperationForArrange(nodes,
factor = 10000, add_new_nodes_in_scene = True) 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() status_message.hide()
if not found_solution_for_all: if not found_solution_for_all:

View file

@ -1,18 +1,19 @@
# Copyright (c) 2021 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime
import json
import random
from hashlib import sha512
from base64 import b64encode from base64 import b64encode
from typing import Optional from datetime import datetime
import requests from hashlib import sha512
from PyQt5.QtNetwork import QNetworkReply
from UM.i18n import i18nCatalog import secrets
from UM.Logger import Logger from typing import Callable, Optional
import urllib.parse
from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settings from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settings
from UM.i18n import i18nCatalog
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To download log-in tokens.
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S" TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
@ -30,14 +31,13 @@ class AuthorizationHelpers:
return self._settings return self._settings
def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str) -> "AuthenticationResponse": def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str, callback: Callable[[AuthenticationResponse], None]) -> None:
"""Request the access token from the authorization server. """
Request the access token from the authorization server.
:param authorization_code: The authorization code from the 1st step. :param authorization_code: The authorization code from the 1st step.
:param verification_code: The verification code needed for the PKCE extension. :param verification_code: The verification code needed for the PKCE extension.
:return: An AuthenticationResponse object. :param callback: Once the token has been obtained, this function will be called with the response.
""" """
data = { data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
"redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "",
@ -46,18 +46,21 @@ class AuthorizationHelpers:
"code_verifier": verification_code, "code_verifier": verification_code,
"scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "", "scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "",
} }
try: headers = {"Content-type": "application/x-www-form-urlencoded"}
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore HttpRequestManager.getInstance().post(
except requests.exceptions.ConnectionError: self._token_url,
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server") data = urllib.parse.urlencode(data).encode("UTF-8"),
headers_dict = headers,
callback = lambda response: self.parseTokenResponse(response, callback),
error_callback = lambda response, _: self.parseTokenResponse(response, callback)
)
def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse": def getAccessTokenUsingRefreshToken(self, refresh_token: str, callback: Callable[[AuthenticationResponse], None]) -> None:
"""Request the access token from the authorization server using a refresh token. """
Request the access token from the authorization server using a refresh token.
:param refresh_token: :param refresh_token: A long-lived token used to refresh the authentication token.
:return: An AuthenticationResponse object. :param callback: Once the token has been obtained, this function will be called with the response.
""" """
Logger.log("d", "Refreshing the access token for [%s]", self._settings.OAUTH_SERVER_URL) Logger.log("d", "Refreshing the access token for [%s]", self._settings.OAUTH_SERVER_URL)
data = { data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
@ -66,84 +69,108 @@ class AuthorizationHelpers:
"refresh_token": refresh_token, "refresh_token": refresh_token,
"scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "", "scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "",
} }
try: headers = {"Content-type": "application/x-www-form-urlencoded"}
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore HttpRequestManager.getInstance().post(
except requests.exceptions.ConnectionError: self._token_url,
return AuthenticationResponse(success = False, err_message = "Unable to connect to remote server") data = urllib.parse.urlencode(data).encode("UTF-8"),
except OSError as e: headers_dict = headers,
return AuthenticationResponse(success = False, err_message = "Operating system is unable to set up a secure connection: {err}".format(err = str(e))) callback = lambda response: self.parseTokenResponse(response, callback),
error_callback = lambda response, _: self.parseTokenResponse(response, callback)
)
@staticmethod def parseTokenResponse(self, token_response: QNetworkReply, callback: Callable[[AuthenticationResponse], None]) -> None:
def parseTokenResponse(token_response: requests.models.Response) -> "AuthenticationResponse":
"""Parse the token response from the authorization server into an AuthenticationResponse object. """Parse the token response from the authorization server into an AuthenticationResponse object.
:param token_response: The JSON string data response from the authorization server. :param token_response: The JSON string data response from the authorization server.
:return: An AuthenticationResponse object. :return: An AuthenticationResponse object.
""" """
token_data = HttpRequestManager.readJSON(token_response)
token_data = None
try:
token_data = json.loads(token_response.text)
except ValueError:
Logger.log("w", "Could not parse token response data: %s", token_response.text)
if not token_data: if not token_data:
return AuthenticationResponse(success = False, err_message = catalog.i18nc("@message", "Could not read response.")) callback(AuthenticationResponse(success = False, err_message = catalog.i18nc("@message", "Could not read response.")))
return
if token_response.status_code not in (200, 201): if token_response.error() != QNetworkReply.NetworkError.NoError:
return AuthenticationResponse(success = False, err_message = token_data["error_description"]) callback(AuthenticationResponse(success = False, err_message = token_data["error_description"]))
return
return AuthenticationResponse(success=True, callback(AuthenticationResponse(success = True,
token_type=token_data["token_type"], token_type = token_data["token_type"],
access_token=token_data["access_token"], access_token = token_data["access_token"],
refresh_token=token_data["refresh_token"], refresh_token = token_data["refresh_token"],
expires_in=token_data["expires_in"], expires_in = token_data["expires_in"],
scope=token_data["scope"], scope = token_data["scope"],
received_at=datetime.now().strftime(TOKEN_TIMESTAMP_FORMAT)) received_at = datetime.now().strftime(TOKEN_TIMESTAMP_FORMAT)))
return
def parseJWT(self, access_token: str) -> Optional["UserProfile"]: def checkToken(self, access_token: str, success_callback: Optional[Callable[[UserProfile], None]] = None, failed_callback: Optional[Callable[[], None]] = None) -> None:
"""Calls the authentication API endpoint to get the token data. """Calls the authentication API endpoint to get the token data.
The API is called asynchronously. When a response is given, the callback is called with the user's profile.
:param access_token: The encoded JWT token. :param access_token: The encoded JWT token.
:return: Dict containing some profile data. :param success_callback: When a response is given, this function will be called with a user profile. If None,
there will not be a callback.
:param failed_callback: When the request failed or the response didn't parse, this function will be called.
""" """
check_token_url = "{}/check-token".format(self._settings.OAUTH_SERVER_URL)
try: Logger.log("d", "Checking the access token for [%s]", check_token_url)
check_token_url = "{}/check-token".format(self._settings.OAUTH_SERVER_URL) headers = {
Logger.log("d", "Checking the access token for [%s]", check_token_url) "Authorization": f"Bearer {access_token}"
token_request = requests.get(check_token_url, headers = { }
"Authorization": "Bearer {}".format(access_token) HttpRequestManager.getInstance().get(
}) check_token_url,
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): headers_dict = headers,
# Connection was suddenly dropped. Nothing we can do about that. callback = lambda reply: self._parseUserProfile(reply, success_callback, failed_callback),
Logger.logException("w", "Something failed while attempting to parse the JWT token") error_callback = lambda _, _2: failed_callback() if failed_callback is not None else None
return None
if token_request.status_code not in (200, 201):
Logger.log("w", "Could not retrieve token data from auth server: %s", token_request.text)
return None
user_data = token_request.json().get("data")
if not user_data or not isinstance(user_data, dict):
Logger.log("w", "Could not parse user data from token: %s", user_data)
return None
return UserProfile(
user_id = user_data["user_id"],
username = user_data["username"],
profile_image_url = user_data.get("profile_image_url", ""),
organization_id = user_data.get("organization", {}).get("organization_id"),
subscriptions = user_data.get("subscriptions", [])
) )
def _parseUserProfile(self, reply: QNetworkReply, success_callback: Optional[Callable[[UserProfile], None]], failed_callback: Optional[Callable[[], None]] = None) -> None:
"""
Parses the user profile from a reply to /check-token.
If the response is valid, the callback will be called to return the user profile to the caller.
:param reply: A network reply to a request to the /check-token URL.
:param success_callback: A function to call once a user profile was successfully obtained.
:param failed_callback: A function to call if parsing the profile failed.
"""
if reply.error() != QNetworkReply.NetworkError.NoError:
Logger.warning(f"Could not access account information. QNetworkError {reply.errorString()}")
if failed_callback is not None:
failed_callback()
return
profile_data = HttpRequestManager.getInstance().readJSON(reply)
if profile_data is None or "data" not in profile_data:
Logger.warning("Could not parse user data from token.")
if failed_callback is not None:
failed_callback()
return
profile_data = profile_data["data"]
required_fields = {"user_id", "username"}
if "user_id" not in profile_data or "username" not in profile_data:
Logger.warning(f"User data missing required field(s): {required_fields - set(profile_data.keys())}")
if failed_callback is not None:
failed_callback()
return
if success_callback is not None:
success_callback(UserProfile(
user_id = profile_data["user_id"],
username = profile_data["username"],
profile_image_url = profile_data.get("profile_image_url", ""),
organization_id = profile_data.get("organization", {}).get("organization_id"),
subscriptions = profile_data.get("subscriptions", [])
))
@staticmethod @staticmethod
def generateVerificationCode(code_length: int = 32) -> str: def generateVerificationCode(code_length: int = 32) -> str:
"""Generate a verification code of arbitrary length. """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 better to leave it at 32
""" """
return "".join(random.choice("0123456789ABCDEF") for i in range(code_length)) return secrets.token_hex(code_length)
@staticmethod @staticmethod
def generateVerificationCodeChallenge(verification_code: str) -> str: def generateVerificationCodeChallenge(verification_code: str) -> str:

View file

@ -2,6 +2,7 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from http.server import BaseHTTPRequestHandler from http.server import BaseHTTPRequestHandler
from threading import Lock # To turn an asynchronous call synchronous.
from typing import Optional, Callable, Tuple, Dict, Any, List, TYPE_CHECKING from typing import Optional, Callable, Tuple, Dict, Any, List, TYPE_CHECKING
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
@ -14,6 +15,7 @@ if TYPE_CHECKING:
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
class AuthorizationRequestHandler(BaseHTTPRequestHandler): class AuthorizationRequestHandler(BaseHTTPRequestHandler):
"""This handler handles all HTTP requests on the local web server. """This handler handles all HTTP requests on the local web server.
@ -24,11 +26,11 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
super().__init__(request, client_address, server) super().__init__(request, client_address, server)
# These values will be injected by the HTTPServer that this handler belongs to. # These values will be injected by the HTTPServer that this handler belongs to.
self.authorization_helpers = None # type: Optional[AuthorizationHelpers] self.authorization_helpers: Optional[AuthorizationHelpers] = None
self.authorization_callback = None # type: Optional[Callable[[AuthenticationResponse], None]] self.authorization_callback: Optional[Callable[[AuthenticationResponse], None]] = None
self.verification_code = None # type: Optional[str] self.verification_code: Optional[str] = None
self.state = None # type: Optional[str] self.state: Optional[str] = None
# CURA-6609: Some browser seems to issue a HEAD instead of GET request as the callback. # CURA-6609: Some browser seems to issue a HEAD instead of GET request as the callback.
def do_HEAD(self) -> None: def do_HEAD(self) -> None:
@ -70,13 +72,23 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
if state != self.state: if state != self.state:
token_response = AuthenticationResponse( token_response = AuthenticationResponse(
success = False, success = False,
err_message=catalog.i18nc("@message", err_message = catalog.i18nc("@message", "The provided state is not correct.")
"The provided state is not correct.")
) )
elif code and self.authorization_helpers is not None and self.verification_code is not None: elif code and self.authorization_helpers is not None and self.verification_code is not None:
token_response = AuthenticationResponse(
success = False,
err_message = catalog.i18nc("@message", "Timeout when authenticating with the account server.")
)
# If the code was returned we get the access token. # If the code was returned we get the access token.
token_response = self.authorization_helpers.getAccessTokenUsingAuthorizationCode( lock = Lock()
code, self.verification_code) lock.acquire()
def callback(response: AuthenticationResponse) -> None:
nonlocal token_response
token_response = response
lock.release()
self.authorization_helpers.getAccessTokenUsingAuthorizationCode(code, self.verification_code, callback)
lock.acquire(timeout = 60) # Block thread until request is completed (which releases the lock). If not acquired, the timeout message stays.
elif self._queryGet(query, "error_code") == "user_denied": elif self._queryGet(query, "error_code") == "user_denied":
# Otherwise we show an error message (probably the user clicked "Deny" in the auth dialog). # Otherwise we show an error message (probably the user clicked "Deny" in the auth dialog).

View file

@ -3,10 +3,9 @@
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, TYPE_CHECKING, Dict from typing import Callable, Dict, Optional, TYPE_CHECKING, Union
from urllib.parse import urlencode, quote_plus from urllib.parse import urlencode, quote_plus
import requests.exceptions
from PyQt5.QtCore import QUrl from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QDesktopServices from PyQt5.QtGui import QDesktopServices
@ -16,7 +15,7 @@ from UM.Signal import Signal
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT
from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
from cura.OAuth2.Models import AuthenticationResponse from cura.OAuth2.Models import AuthenticationResponse, BaseModel
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")
@ -24,7 +23,8 @@ if TYPE_CHECKING:
from cura.OAuth2.Models import UserProfile, OAuth2Settings from cura.OAuth2.Models import UserProfile, OAuth2Settings
from UM.Preferences import Preferences 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: class AuthorizationService:
"""The authorization service is responsible for handling the login flow, storing user credentials and providing """The authorization service is responsible for handling the login flow, storing user credentials and providing
@ -43,12 +43,13 @@ class AuthorizationService:
self._settings = settings self._settings = settings
self._auth_helpers = AuthorizationHelpers(settings) self._auth_helpers = AuthorizationHelpers(settings)
self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL) self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL)
self._auth_data = None # type: Optional[AuthenticationResponse] self._auth_data: Optional[AuthenticationResponse] = None
self._user_profile = None # type: Optional["UserProfile"] self._user_profile: Optional["UserProfile"] = None
self._preferences = preferences self._preferences = preferences
self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True) self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True)
self._currently_refreshing_token = False # Whether we are currently in the process of refreshing auth. Don't make new requests while busy.
self._unable_to_get_data_message = None # type: Optional[Message] self._unable_to_get_data_message: Optional[Message] = None
self.onAuthStateChanged.connect(self._authChanged) self.onAuthStateChanged.connect(self._authChanged)
@ -62,65 +63,83 @@ class AuthorizationService:
if self._preferences: if self._preferences:
self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}")
def getUserProfile(self) -> Optional["UserProfile"]: def getUserProfile(self, callback: Optional[Callable[[Optional["UserProfile"]], None]] = None) -> None:
"""Get the user profile as obtained from the JWT (JSON Web Token). """
Get the user profile as obtained from the JWT (JSON Web Token).
If the JWT is not yet parsed, calling this will take care of that. If the JWT is not yet checked and parsed, calling this will take care of that.
:param callback: Once the user profile is obtained, this function will be called with the given user profile. If
:return: UserProfile if a user is logged in, None otherwise. the profile fails to be obtained, this function will be called with None.
See also: :py:method:`cura.OAuth2.AuthorizationService.AuthorizationService._parseJWT` See also: :py:method:`cura.OAuth2.AuthorizationService.AuthorizationService._parseJWT`
""" """
if self._user_profile:
# We already obtained the profile. No need to make another request for it.
if callback is not None:
callback(self._user_profile)
return
if not self._user_profile: # If no user profile was stored locally, we try to get it from JWT.
# If no user profile was stored locally, we try to get it from JWT. def store_profile(profile: Optional["UserProfile"]) -> None:
try: if profile is not None:
self._user_profile = self._parseJWT() self._user_profile = profile
except requests.exceptions.ConnectionError: if callback is not None:
# Unable to get connection, can't login. callback(profile)
Logger.logException("w", "Unable to validate user data with the remote server.") elif self._auth_data:
return None # If there is no user profile from the JWT, we have to log in again.
Logger.warning("The user profile could not be loaded. The user must log in again!")
self.deleteAuthData()
if callback is not None:
callback(None)
else:
if callback is not None:
callback(None)
if not self._user_profile and self._auth_data: self._parseJWT(callback = store_profile)
# If there is still no user profile from the JWT, we have to log in again.
Logger.log("w", "The user profile could not be loaded. The user must log in again!")
self.deleteAuthData()
return None
return self._user_profile def _parseJWT(self, callback: Callable[[Optional["UserProfile"]], None]) -> None:
"""
def _parseJWT(self) -> Optional["UserProfile"]: Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there.
"""Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there. :param callback: A function to call asynchronously once the user profile has been obtained. It will be called
with `None` if it failed to obtain a user profile.
:return: UserProfile if it was able to parse, None otherwise.
""" """
if not self._auth_data or self._auth_data.access_token is None: if not self._auth_data or self._auth_data.access_token is None:
# If no auth data exists, we should always log in again. # If no auth data exists, we should always log in again.
Logger.log("d", "There was no auth data or access token") Logger.debug("There was no auth data or access token")
return None callback(None)
user_data = self._auth_helpers.parseJWT(self._auth_data.access_token) return
if user_data:
# If the profile was found, we return it immediately. # When we checked the token we may get a user profile. This callback checks if that is a valid one and tries to refresh the token if it's not.
return user_data def check_user_profile(user_profile: Optional["UserProfile"]) -> None:
# The JWT was expired or invalid and we should request a new one. if user_profile:
if self._auth_data.refresh_token is None: # If the profile was found, we call it back immediately.
Logger.log("w", "There was no refresh token in the auth data.") callback(user_profile)
return None return
self._auth_data = self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token) # The JWT was expired or invalid and we should request a new one.
if not self._auth_data or self._auth_data.access_token is None: if self._auth_data is None or self._auth_data.refresh_token is None:
Logger.log("w", "Unable to use the refresh token to get a new access token.") Logger.warning("There was no refresh token in the auth data.")
# The token could not be refreshed using the refresh token. We should login again. callback(None)
return None return
# Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been deleted
# from the server already. Do not store the auth_data if we could not get new auth_data (eg due to a def process_auth_data(auth_data: AuthenticationResponse) -> None:
# network error), since this would cause an infinite loop trying to get new auth-data if auth_data.access_token is None:
if self._auth_data.success: Logger.warning("Unable to use the refresh token to get a new access token.")
self._storeAuthData(self._auth_data) callback(None)
return self._auth_helpers.parseJWT(self._auth_data.access_token) return
# Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been
# deleted from the server already. Do not store the auth_data if we could not get new auth_data (e.g.
# due to a network error), since this would cause an infinite loop trying to get new auth-data.
if auth_data.success:
self._storeAuthData(auth_data)
self._auth_helpers.checkToken(auth_data.access_token, callback, lambda: callback(None))
self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token, process_auth_data)
self._auth_helpers.checkToken(self._auth_data.access_token, check_user_profile, lambda: check_user_profile(None))
def getAccessToken(self) -> Optional[str]: 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: if self._auth_data is None:
Logger.log("d", "No auth data to retrieve the access_token from") Logger.log("d", "No auth data to retrieve the access_token from")
@ -142,13 +161,20 @@ class AuthorizationService:
if self._auth_data is None or self._auth_data.refresh_token is None: if self._auth_data is None or self._auth_data.refresh_token is None:
Logger.log("w", "Unable to refresh access token, since there is no refresh token.") Logger.log("w", "Unable to refresh access token, since there is no refresh token.")
return return
response = self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)
if response.success: def process_auth_data(response: AuthenticationResponse) -> None:
self._storeAuthData(response) if response.success:
self.onAuthStateChanged.emit(logged_in = True) self._storeAuthData(response)
else: self.onAuthStateChanged.emit(logged_in = True)
Logger.log("w", "Failed to get a new access token from the server.") else:
self.onAuthStateChanged.emit(logged_in = False) Logger.warning("Failed to get a new access token from the server.")
self.onAuthStateChanged.emit(logged_in = False)
if self._currently_refreshing_token:
Logger.debug("Was already busy refreshing token. Do not start a new request.")
return
self._currently_refreshing_token = True
self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token, process_auth_data)
def deleteAuthData(self) -> None: def deleteAuthData(self) -> None:
"""Delete the authentication data that we have stored locally (eg; logout)""" """Delete the authentication data that we have stored locally (eg; logout)"""
@ -209,25 +235,27 @@ class AuthorizationService:
link to force the a browser logout from mycloud.ultimaker.com link to force the a browser logout from mycloud.ultimaker.com
:return: The authentication URL, properly formatted and encoded :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: if force_browser_logout:
# The url after '?next=' should be urlencoded connecting_char = "&" if "?" in MYCLOUD_LOGOFF_URL else "?"
auth_url = "{}?next={}".format(MYCLOUD_LOGOFF_URL, quote_plus(auth_url)) # The url after 'next=' should be urlencoded
auth_url = f"{MYCLOUD_LOGOFF_URL}{connecting_char}next={quote_plus(auth_url)}"
return auth_url return auth_url
def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None: def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:
"""Callback method for the authentication flow.""" """Callback method for the authentication flow."""
if auth_response.success: if auth_response.success:
Logger.log("d", "Got callback from Authorization state. The user should now be logged in!")
self._storeAuthData(auth_response) self._storeAuthData(auth_response)
self.onAuthStateChanged.emit(logged_in = True) self.onAuthStateChanged.emit(logged_in = True)
else: 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.onAuthenticationError.emit(logged_in = False, error_message = auth_response.err_message)
self._server.stop() # Stop the web server at all times. self._server.stop() # Stop the web server at all times.
def loadAuthDataFromPreferences(self) -> None: def loadAuthDataFromPreferences(self) -> None:
"""Load authentication data from preferences.""" """Load authentication data from preferences."""
Logger.log("d", "Attempting to load the auth data from preferences.")
if self._preferences is None: if self._preferences is None:
Logger.log("e", "Unable to load authentication data, since no preference has been set!") Logger.log("e", "Unable to load authentication data, since no preference has been set!")
return return
@ -235,19 +263,23 @@ class AuthorizationService:
preferences_data = json.loads(self._preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY)) preferences_data = json.loads(self._preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY))
if preferences_data: if preferences_data:
self._auth_data = AuthenticationResponse(**preferences_data) self._auth_data = AuthenticationResponse(**preferences_data)
# Also check if we can actually get the user profile information.
user_profile = self.getUserProfile()
if user_profile is not None:
self.onAuthStateChanged.emit(logged_in = True)
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", # Also check if we can actually get the user profile information.
"Unable to reach the Ultimaker account server."), def callback(profile: Optional["UserProfile"]) -> None:
title = i18n_catalog.i18nc("@info:title", "Warning"), if profile is not None:
message_type = Message.MessageType.ERROR) self.onAuthStateChanged.emit(logged_in = True)
self._unable_to_get_data_message.show() Logger.debug("Auth data was successfully loaded")
else:
if self._unable_to_get_data_message is not None:
self._unable_to_get_data_message.show()
else:
self._unable_to_get_data_message = Message(i18n_catalog.i18nc("@info",
"Unable to reach the Ultimaker account server."),
title = i18n_catalog.i18nc("@info:title", "Log-in failed"),
message_type = Message.MessageType.ERROR)
Logger.warning("Unable to get user profile using auth data from preferences.")
self._unable_to_get_data_message.show()
self.getUserProfile(callback)
except (ValueError, TypeError): except (ValueError, TypeError):
Logger.logException("w", "Could not load auth data from preferences") Logger.logException("w", "Could not load auth data from preferences")
@ -260,10 +292,12 @@ class AuthorizationService:
return return
self._auth_data = auth_data self._auth_data = auth_data
self._currently_refreshing_token = False
if auth_data: if auth_data:
self._user_profile = self.getUserProfile() self.getUserProfile()
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(auth_data.dump())) self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(auth_data.dump()))
else: else:
Logger.log("d", "Clearing the user profile")
self._user_profile = None self._user_profile = None
self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY) self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)

View file

@ -2,9 +2,10 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Type, TYPE_CHECKING, Optional, List from typing import Type, TYPE_CHECKING, Optional, List
from io import BlockingIOError
import keyring import keyring
from keyring.backend import KeyringBackend from keyring.backend import KeyringBackend
from keyring.errors import NoKeyringError, PasswordSetError, KeyringLocked from keyring.errors import NoKeyringError, PasswordSetError, KeyringLocked, KeyringError
from UM.Logger import Logger from UM.Logger import Logger
@ -14,13 +15,18 @@ if TYPE_CHECKING:
# Need to do some extra workarounds on windows: # Need to do some extra workarounds on windows:
import sys import sys
from UM.Platform import Platform from UM.Platform import Platform
if Platform.isWindows() and hasattr(sys, "frozen"): if Platform.isWindows():
import win32timezone if hasattr(sys, "frozen"):
import win32timezone
from keyring.backends.Windows import WinVaultKeyring from keyring.backends.Windows import WinVaultKeyring
keyring.set_keyring(WinVaultKeyring()) keyring.set_keyring(WinVaultKeyring())
if Platform.isOSX() and hasattr(sys, "frozen"): if Platform.isOSX():
from keyring.backends.macOS import Keyring from keyring.backends.macOS import Keyring
keyring.set_keyring(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: # Even if errors happen, we don't want this stored locally:
DONT_EVER_STORE_LOCALLY: List[str] = ["refresh_token"] DONT_EVER_STORE_LOCALLY: List[str] = ["refresh_token"]
@ -39,10 +45,18 @@ class KeyringAttribute:
self._store_secure = False self._store_secure = False
Logger.logException("w", "No keyring backend present") Logger.logException("w", "No keyring backend present")
return getattr(instance, self._name) return getattr(instance, self._name)
except KeyringLocked: except (KeyringLocked, BlockingIOError):
self._store_secure = False self._store_secure = False
Logger.log("i", "Access to the keyring was denied.") Logger.log("i", "Access to the keyring was denied.")
return getattr(instance, self._name) 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)
except KeyringError:
self._store_secure = False
Logger.logException("w", "Unknown keyring error.")
return getattr(instance, self._name)
else: else:
return getattr(instance, self._name) return getattr(instance, self._name)

View file

@ -72,14 +72,14 @@ class PickingPass(RenderPass):
window_size = self._renderer.getWindowSize() window_size = self._renderer.getWindowSize()
px = (0.5 + x / 2.0) * window_size[0] px = int((0.5 + x / 2.0) * window_size[0])
py = (0.5 + y / 2.0) * window_size[1] py = int((0.5 + y / 2.0) * window_size[1])
if px < 0 or px > (output.width() - 1) or py < 0 or py > (output.height() - 1): if px < 0 or px > (output.width() - 1) or py < 0 or py > (output.height() - 1):
return -1 return -1
distance = output.pixel(px, py) # distance in micron, from in r, g & b channels 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 return distance
def getPickedPosition(self, x: int, y: int) -> Vector: def getPickedPosition(self, x: int, y: int) -> Vector:

View file

@ -1,8 +1,7 @@
# Copyright (c) 2020 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import QTimer from PyQt5.QtCore import QTimer
from shapely.errors import TopologicalError # To capture errors if Shapely messes up.
from UM.Application import Application from UM.Application import Application
from UM.Logger import Logger from UM.Logger import Logger
@ -138,11 +137,7 @@ class PlatformPhysics:
own_convex_hull = node.callDecoration("getConvexHull") own_convex_hull = node.callDecoration("getConvexHull")
other_convex_hull = other_node.callDecoration("getConvexHull") other_convex_hull = other_node.callDecoration("getConvexHull")
if own_convex_hull and other_convex_hull: if own_convex_hull and other_convex_hull:
try: overlap = own_convex_hull.translate(move_vector.x, move_vector.z).intersectsPolygon(other_convex_hull)
overlap = own_convex_hull.translate(move_vector.x, move_vector.z).intersectsPolygon(other_convex_hull)
except TopologicalError as e: # Can happen if the convex hull is degenerate?
Logger.warning("Got a topological error when calculating convex hull intersection: {err}".format(err = str(e)))
overlap = False
if overlap: # Moving ensured that overlap was still there. Try anew! if overlap: # Moving ensured that overlap was still there. Try anew!
temp_move_vector = move_vector.set(x = move_vector.x + overlap[0] * self._move_factor, temp_move_vector = move_vector.set(x = move_vector.x + overlap[0] * self._move_factor,
z = move_vector.z + overlap[1] * self._move_factor) z = move_vector.z + overlap[1] * self._move_factor)

View file

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

View file

@ -42,7 +42,7 @@ class PrintJobOutputModel(QObject):
self._preview_image = None # type: Optional[QImage] self._preview_image = None # type: Optional[QImage]
@pyqtProperty("QStringList", notify=compatibleMachineFamiliesChanged) @pyqtProperty("QStringList", notify=compatibleMachineFamiliesChanged)
def compatibleMachineFamilies(self): def compatibleMachineFamilies(self) -> List[str]:
# Hack; Some versions of cluster will return a family more than once... # Hack; Some versions of cluster will return a family more than once...
return list(set(self._compatible_machine_families)) return list(set(self._compatible_machine_families))
@ -77,11 +77,11 @@ class PrintJobOutputModel(QObject):
self._configuration = configuration self._configuration = configuration
self.configurationChanged.emit() self.configurationChanged.emit()
@pyqtProperty(str, notify=ownerChanged) @pyqtProperty(str, notify = ownerChanged)
def owner(self): def owner(self) -> str:
return self._owner return self._owner
def updateOwner(self, owner): def updateOwner(self, owner: str) -> None:
if self._owner != owner: if self._owner != owner:
self._owner = owner self._owner = owner
self.ownerChanged.emit() self.ownerChanged.emit()
@ -132,7 +132,7 @@ class PrintJobOutputModel(QObject):
@pyqtProperty(float, notify = timeElapsedChanged) @pyqtProperty(float, notify = timeElapsedChanged)
def progress(self) -> float: def progress(self) -> float:
result = float(self.timeElapsed) / max(self.timeTotal, 1.0) # Prevent a division by zero exception. result = float(self.timeElapsed) / max(self.timeTotal, 1.0) # Prevent a division by zero exception.
return min(result, 1.0) # Never get a progress past 1.0 return min(result, 1.0) # Never get a progress past 1.0
@pyqtProperty(str, notify=stateChanged) @pyqtProperty(str, notify=stateChanged)
@ -151,12 +151,12 @@ class PrintJobOutputModel(QObject):
return False return False
return True return True
def updateTimeTotal(self, new_time_total): def updateTimeTotal(self, new_time_total: int) -> None:
if self._time_total != new_time_total: if self._time_total != new_time_total:
self._time_total = new_time_total self._time_total = new_time_total
self.timeTotalChanged.emit() self.timeTotalChanged.emit()
def updateTimeElapsed(self, new_time_elapsed): def updateTimeElapsed(self, new_time_elapsed: int) -> None:
if self._time_elapsed != new_time_elapsed: if self._time_elapsed != new_time_elapsed:
self._time_elapsed = new_time_elapsed self._time_elapsed = new_time_elapsed
self.timeElapsedChanged.emit() self.timeElapsedChanged.emit()

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. # Cura is released under the terms of the LGPLv3 or higher.
from UM.FileHandler.FileHandler import FileHandler #For typing. from UM.FileHandler.FileHandler import FileHandler #For typing.
@ -114,6 +114,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
return b"".join(file_data_bytes_list) return b"".join(file_data_bytes_list)
def _update(self) -> None: def _update(self) -> None:
"""
Update the connection state of this device.
This is called on regular intervals.
"""
if self._last_response_time: if self._last_response_time:
time_since_last_response = time() - self._last_response_time time_since_last_response = time() - self._last_response_time
else: else:
@ -127,11 +132,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
if time_since_last_response > self._timeout_time >= time_since_last_request: if time_since_last_response > self._timeout_time >= time_since_last_request:
# Go (or stay) into timeout. # Go (or stay) into timeout.
if self._connection_state_before_timeout is None: if self._connection_state_before_timeout is None:
self._connection_state_before_timeout = self._connection_state self._connection_state_before_timeout = self.connectionState
self.setConnectionState(ConnectionState.Closed) self.setConnectionState(ConnectionState.Closed)
elif self._connection_state == ConnectionState.Closed: elif self.connectionState == ConnectionState.Closed:
# Go out of timeout. # Go out of timeout.
if self._connection_state_before_timeout is not None: # sanity check, but it should never be None here if self._connection_state_before_timeout is not None: # sanity check, but it should never be None here
self.setConnectionState(self._connection_state_before_timeout) self.setConnectionState(self._connection_state_before_timeout)
@ -361,7 +366,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
self._last_response_time = time() self._last_response_time = time()
if self._connection_state == ConnectionState.Connecting: if self.connectionState == ConnectionState.Connecting:
self.setConnectionState(ConnectionState.Connected) self.setConnectionState(ConnectionState.Connected)
callback_key = reply.url().toString() + str(reply.operation()) callback_key = reply.url().toString() + str(reply.operation())
@ -414,6 +419,6 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def ipAddress(self) -> str: def ipAddress(self) -> str:
"""IP adress of this printer""" """IP address of this printer"""
return self._address return self._address

View file

@ -1,11 +1,13 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from enum import IntEnum from enum import IntEnum
from typing import Callable, List, Optional, Union from typing import Callable, List, Optional, Union
from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, QTimer, QUrl from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, QTimer, QUrl
from PyQt5.QtWidgets import QMessageBox from PyQt5.QtWidgets import QMessageBox
import cura.CuraApplication # Imported like this to prevent circular imports.
from UM.Logger import Logger from UM.Logger import Logger
from UM.Signal import signalemitter from UM.Signal import signalemitter
from UM.Qt.QtApplication import QtApplication from UM.Qt.QtApplication import QtApplication
@ -120,11 +122,22 @@ class PrinterOutputDevice(QObject, OutputDevice):
callback(QMessageBox.Yes) callback(QMessageBox.Yes)
def isConnected(self) -> bool: def isConnected(self) -> bool:
return self._connection_state != ConnectionState.Closed and self._connection_state != ConnectionState.Error """
Returns whether we could theoretically send commands to this printer.
:return: `True` if we are connected, or `False` if not.
"""
return self.connectionState != ConnectionState.Closed and self.connectionState != ConnectionState.Error
def setConnectionState(self, connection_state: "ConnectionState") -> None: def setConnectionState(self, connection_state: "ConnectionState") -> None:
if self._connection_state != connection_state: """
Store the connection state of the printer.
Causes everything that displays the connection state to update its QML models.
:param connection_state: The new connection state to store.
"""
if self.connectionState != connection_state:
self._connection_state = connection_state self._connection_state = connection_state
cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack().setMetaDataEntry("is_online", self.isConnected())
self.connectionStateChanged.emit(self._id) self.connectionStateChanged.emit(self._id)
@pyqtProperty(int, constant = True) @pyqtProperty(int, constant = True)
@ -133,6 +146,10 @@ class PrinterOutputDevice(QObject, OutputDevice):
@pyqtProperty(int, notify = connectionStateChanged) @pyqtProperty(int, notify = connectionStateChanged)
def connectionState(self) -> "ConnectionState": def connectionState(self) -> "ConnectionState":
"""
Get the connection state of the printer, e.g. whether it is connected, still connecting, error state, etc.
:return: The current connection state of this output device.
"""
return self._connection_state return self._connection_state
def _update(self) -> None: def _update(self) -> None:

View file

@ -0,0 +1,264 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import enum
import functools # For partial methods to use as callbacks with information pre-filled.
import json # To serialise metadata for API calls.
import os # To delete the archive when we're done.
from PyQt5.QtCore import QUrl
import tempfile # To create an archive before we upload it.
import cura.CuraApplication # Imported like this to prevent circular imports.
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To find all printers to upload to.
from cura.UltimakerCloud import UltimakerCloudConstants # To know where the API is.
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To know how to communicate with this server.
from UM.i18n import i18nCatalog
from UM.Job import Job
from UM.Logger import Logger
from UM.Signal import Signal
from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To call the API.
from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
from typing import Any, cast, Dict, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from PyQt5.QtNetwork import QNetworkReply
from cura.UltimakerCloud.CloudMaterialSync import CloudMaterialSync
catalog = i18nCatalog("cura")
class UploadMaterialsError(Exception):
"""
Class to indicate something went wrong while uploading.
"""
pass
class UploadMaterialsJob(Job):
"""
Job that uploads a set of materials to the Digital Factory.
The job has a number of stages:
- First, it generates an archive of all materials. This typically takes a lot of processing power during which the
GIL remains locked.
- Then it requests the API to upload an archive.
- Then it uploads the archive to the URL given by the first request.
- Then it tells the API that the archive can be distributed to the printers.
"""
UPLOAD_REQUEST_URL = f"{UltimakerCloudConstants.CuraCloudAPIRoot}/connect/v1/materials/upload"
UPLOAD_CONFIRM_URL = UltimakerCloudConstants.CuraCloudAPIRoot + "/connect/v1/clusters/{cluster_id}/printers/{cluster_printer_id}/action/import_material"
class Result(enum.IntEnum):
SUCCESS = 0
FAILED = 1
class PrinterStatus(enum.Enum):
UPLOADING = "uploading"
SUCCESS = "success"
FAILED = "failed"
def __init__(self, material_sync: "CloudMaterialSync"):
super().__init__()
self._material_sync = material_sync
self._scope = JsonDecoratorScope(UltimakerCloudScope(cura.CuraApplication.CuraApplication.getInstance())) # type: JsonDecoratorScope
self._archive_filename = None # type: Optional[str]
self._archive_remote_id = None # type: Optional[str] # ID that the server gives to this archive. Used to communicate about the archive to the server.
self._printer_sync_status = {} # type: Dict[str, str]
self._printer_metadata = [] # type: List[Dict[str, Any]]
self.processProgressChanged.connect(self._onProcessProgressChanged)
uploadCompleted = Signal() # Triggered when the job is really complete, including uploading to the cloud.
processProgressChanged = Signal() # Triggered when we've made progress creating the archive.
uploadProgressChanged = Signal() # Triggered when we've made progress with the complete job. This signal emits a progress fraction (0-1) as well as the status of every printer.
def run(self) -> None:
"""
Generates an archive of materials and starts uploading that archive to the cloud.
"""
self._printer_metadata = CuraContainerRegistry.getInstance().findContainerStacksMetadata(
type = "machine",
connection_type = "3", # Only cloud printers.
is_online = "True", # Only online printers. Otherwise the server gives an error.
host_guid = "*", # Required metadata field. Otherwise we get a KeyError.
um_cloud_cluster_id = "*" # Required metadata field. Otherwise we get a KeyError.
)
# Filter out any printer not capable of the 'import_material' capability. Needs FW 7.0.1-RC at the least!
self._printer_metadata = [ printer_data for printer_data in self._printer_metadata if (
UltimakerCloudConstants.META_CAPABILITIES in printer_data and
"import_material" in printer_data[UltimakerCloudConstants.META_CAPABILITIES]
)
]
for printer in self._printer_metadata:
self._printer_sync_status[printer["host_guid"]] = self.PrinterStatus.UPLOADING.value
try:
archive_file = tempfile.NamedTemporaryFile("wb", delete = False)
archive_file.close()
self._archive_filename = archive_file.name
self._material_sync.exportAll(QUrl.fromLocalFile(self._archive_filename), notify_progress = self.processProgressChanged)
except OSError as e:
Logger.error(f"Failed to create archive of materials to sync with printers: {type(e)} - {e}")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to create archive of materials to sync with printers.")))
return
try:
file_size = os.path.getsize(self._archive_filename)
except OSError as e:
Logger.error(f"Failed to load the archive of materials to sync it with printers: {type(e)} - {e}")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to load the archive of materials to sync it with printers.")))
return
request_metadata = {
"data": {
"file_size": file_size,
"material_profile_name": "cura.umm", # File name can be anything as long as it's .umm. It's not used by anyone.
"content_type": "application/zip", # This endpoint won't receive files of different MIME types.
"origin": "cura" # Some identifier against hackers intercepting this upload request, apparently.
}
}
request_payload = json.dumps(request_metadata).encode("UTF-8")
http = HttpRequestManager.getInstance()
http.put(
url = self.UPLOAD_REQUEST_URL,
data = request_payload,
callback = self.onUploadRequestCompleted,
error_callback = self.onError,
scope = self._scope
)
def onUploadRequestCompleted(self, reply: "QNetworkReply") -> None:
"""
Triggered when we successfully requested to upload a material archive.
We then need to start uploading the material archive to the URL that the request answered with.
:param reply: The reply from the server to our request to upload an archive.
"""
response_data = HttpRequestManager.readJSON(reply)
if response_data is None:
Logger.error(f"Invalid response to material upload request. Could not parse JSON data.")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory appears to be corrupted.")))
return
if "data" not in response_data:
Logger.error(f"Invalid response to material upload request: Missing 'data' field that contains the entire response.")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information.")))
return
if "upload_url" not in response_data["data"]:
Logger.error(f"Invalid response to material upload request: Missing 'upload_url' field to upload archive to.")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information.")))
return
if "material_profile_id" not in response_data["data"]:
Logger.error(f"Invalid response to material upload request: Missing 'material_profile_id' to communicate about the materials with the server.")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information.")))
return
upload_url = response_data["data"]["upload_url"]
self._archive_remote_id = response_data["data"]["material_profile_id"]
try:
with open(cast(str, self._archive_filename), "rb") as f:
file_data = f.read()
except OSError as e:
Logger.error(f"Failed to load archive back in for sending to cloud: {type(e)} - {e}")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to load the archive of materials to sync it with printers.")))
return
http = HttpRequestManager.getInstance()
http.put(
url = upload_url,
data = file_data,
callback = self.onUploadCompleted,
error_callback = self.onError,
scope = self._scope
)
def onUploadCompleted(self, reply: "QNetworkReply") -> None:
"""
When we've successfully uploaded the archive to the cloud, we need to notify the API to start syncing that
archive to every printer.
:param reply: The reply from the cloud storage when the upload succeeded.
"""
for container_stack in self._printer_metadata:
cluster_id = container_stack["um_cloud_cluster_id"]
printer_id = container_stack["host_guid"]
http = HttpRequestManager.getInstance()
http.post(
url = self.UPLOAD_CONFIRM_URL.format(cluster_id = cluster_id, cluster_printer_id = printer_id),
callback = functools.partial(self.onUploadConfirmed, printer_id),
error_callback = functools.partial(self.onUploadConfirmed, printer_id), # Let this same function handle the error too.
scope = self._scope,
data = json.dumps({"data": {"material_profile_id": self._archive_remote_id}}).encode("UTF-8")
)
def onUploadConfirmed(self, printer_id: str, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"] = None) -> None:
"""
Triggered when we've got a confirmation that the material is synced with the printer, or that syncing failed.
If syncing succeeded we mark this printer as having the status "success". If it failed we mark the printer as
"failed". If this is the last upload that needed to be completed, we complete the job with either a success
state (every printer successfully synced) or a failed state (any printer failed).
:param printer_id: The printer host_guid that we completed syncing with.
:param reply: The reply that the server gave to confirm.
:param error: If the request failed, this error gives an indication what happened.
"""
if error is not None:
Logger.error(f"Failed to confirm uploading material archive to printer {printer_id}: {error}")
self._printer_sync_status[printer_id] = self.PrinterStatus.FAILED.value
else:
self._printer_sync_status[printer_id] = self.PrinterStatus.SUCCESS.value
still_uploading = len([val for val in self._printer_sync_status.values() if val == self.PrinterStatus.UPLOADING.value])
self.uploadProgressChanged.emit(0.8 + (len(self._printer_sync_status) - still_uploading) / len(self._printer_sync_status), self.getPrinterSyncStatus())
if still_uploading == 0: # This is the last response to be processed.
if self.PrinterStatus.FAILED.value in self._printer_sync_status.values():
self.setResult(self.Result.FAILED)
self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory to sync materials with some of the printers.")))
else:
self.setResult(self.Result.SUCCESS)
self.uploadCompleted.emit(self.getResult(), self.getError())
def onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None:
"""
Used as callback from HTTP requests when the request failed.
The given network error from the `HttpRequestManager` is logged, and the job is marked as failed.
:param reply: The main reply of the server. This reply will most likely not be valid.
:param error: The network error (Qt's enum) that occurred.
"""
Logger.error(f"Failed to upload material archive: {error}")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory.")))
def getPrinterSyncStatus(self) -> Dict[str, str]:
"""
For each printer, identified by host_guid, this gives the current status of uploading the material archive.
The possible states are given in the PrinterStatus enum.
:return: A dictionary with printer host_guids as keys, and their status as values.
"""
return self._printer_sync_status
def failed(self, error: UploadMaterialsError) -> None:
"""
Helper function for when we have a general failure.
This sets the sync status for all printers to failed, sets the error on
the job and the result of the job to FAILED.
:param error: An error to show to the user.
"""
self.setResult(self.Result.FAILED)
self.setError(error)
for printer_id in self._printer_sync_status:
self._printer_sync_status[printer_id] = self.PrinterStatus.FAILED.value
self.uploadProgressChanged.emit(1.0, self.getPrinterSyncStatus())
self.uploadCompleted.emit(self.getResult(), self.getError())
def _onProcessProgressChanged(self, progress: float) -> None:
"""
When we progress in the process of uploading materials, we not only signal the new progress (float from 0 to 1)
but we also signal the current status of every printer. These are emitted as the two parameters of the signal.
:param progress: The progress of this job, between 0 and 1.
"""
self.uploadProgressChanged.emit(progress * 0.8, self.getPrinterSyncStatus()) # The processing is 80% of the progress bar.

View file

@ -23,6 +23,8 @@ from UM.Settings.InstanceContainer import InstanceContainer
import cura.CuraApplication import cura.CuraApplication
from cura.Machines.ContainerTree import ContainerTree from cura.Machines.ContainerTree import ContainerTree
from cura.Settings.ExtruderStack import ExtruderStack
from cura.Settings.GlobalStack import GlobalStack
if TYPE_CHECKING: if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
@ -319,7 +321,7 @@ class ContainerManager(QObject):
stack.qualityChanges = quality_changes stack.qualityChanges = quality_changes
if not quality_changes or container_registry.isReadOnly(quality_changes.getId()): 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 continue
self._performMerge(quality_changes, stack.getTop()) self._performMerge(quality_changes, stack.getTop())
@ -408,7 +410,7 @@ class ContainerManager(QObject):
container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
for plugin_id, container_type in container_registry.getContainerTypes(): for plugin_id, container_type in container_registry.getContainerTypes():
# Ignore default container types since those are not plugins # 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 continue
serialize_type = "" serialize_type = ""

View file

@ -32,6 +32,10 @@ from cura.Machines.ContainerTree import ContainerTree
from cura.ReaderWriters.ProfileReader import NoProfileException, ProfileReader from cura.ReaderWriters.ProfileReader import NoProfileException, ProfileReader
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from .DatabaseHandlers.IntentDatabaseHandler import IntentDatabaseHandler
from .DatabaseHandlers.QualityDatabaseHandler import QualityDatabaseHandler
from .DatabaseHandlers.VariantDatabaseHandler import VariantDatabaseHandler
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
@ -44,6 +48,10 @@ class CuraContainerRegistry(ContainerRegistry):
# is added, we check to see if an extruder stack needs to be added. # is added, we check to see if an extruder stack needs to be added.
self.containerAdded.connect(self._onContainerAdded) self.containerAdded.connect(self._onContainerAdded)
self._database_handlers["variant"] = VariantDatabaseHandler()
self._database_handlers["quality"] = QualityDatabaseHandler()
self._database_handlers["intent"] = IntentDatabaseHandler()
@override(ContainerRegistry) @override(ContainerRegistry)
def addContainer(self, container: ContainerInterface) -> bool: def addContainer(self, container: ContainerInterface) -> bool:
"""Overridden from ContainerRegistry """Overridden from ContainerRegistry

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))) Logger.logException("e", "Failed to create an extruder stack for position {pos}: {err}".format(pos = position, err = str(e)))
return None 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 # not return the correct extruder list (since by default, the machine_extruder_count is 1) in machines with
# settable number of extruders. # settable number of extruders.
if machine_extruder_count and 0 <= machine_extruder_count <= len(extruder_dict): 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

@ -12,6 +12,7 @@ from UM.Scene.SceneNode import SceneNode
from UM.Scene.Selection import Selection from UM.Scene.Selection import Selection
from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator
from UM.Settings.ContainerRegistry import ContainerRegistry # Finding containers by ID. from UM.Settings.ContainerRegistry import ContainerRegistry # Finding containers by ID.
from cura.Machines.ContainerTree import ContainerTree
from typing import Any, cast, Dict, List, Optional, TYPE_CHECKING, Union from typing import Any, cast, Dict, List, Optional, TYPE_CHECKING, Union
@ -258,11 +259,21 @@ class ExtruderManager(QObject):
if support_roof_enabled: if support_roof_enabled:
used_extruder_stack_ids.add(self.extruderIds[self.extruderValueWithDefault(str(global_stack.getProperty("support_roof_extruder_nr", "value")))]) used_extruder_stack_ids.add(self.extruderIds[self.extruderValueWithDefault(str(global_stack.getProperty("support_roof_extruder_nr", "value")))])
# The platform adhesion extruder. Not used if using none. # The platform adhesion extruders.
if global_stack.getProperty("adhesion_type", "value") != "none" or ( used_adhesion_extruders = set()
global_stack.getProperty("prime_tower_brim_enable", "value") and adhesion_type = global_stack.getProperty("adhesion_type", "value")
global_stack.getProperty("adhesion_type", "value") != 'raft'): if adhesion_type == "skirt" and (global_stack.getProperty("skirt_line_count", "value") > 0 or global_stack.getProperty("skirt_brim_minimal_length", "value") > 0):
extruder_str_nr = str(global_stack.getProperty("adhesion_extruder_nr", "value")) used_adhesion_extruders.add("skirt_brim_extruder_nr") # There's a skirt.
if (adhesion_type == "brim" or global_stack.getProperty("prime_tower_brim_enable", "value")) and (global_stack.getProperty("brim_line_count", "value") > 0 or global_stack.getProperty("skirt_brim_minimal_length", "value") > 0):
used_adhesion_extruders.add("skirt_brim_extruder_nr") # There's a brim or prime tower brim.
if adhesion_type == "raft":
used_adhesion_extruders.add("raft_base_extruder_nr")
if global_stack.getProperty("raft_interface_layers", "value") > 0:
used_adhesion_extruders.add("raft_interface_extruder_nr")
if global_stack.getProperty("raft_surface_layers", "value") > 0:
used_adhesion_extruders.add("raft_surface_extruder_nr")
for extruder_setting in used_adhesion_extruders:
extruder_str_nr = str(global_stack.getProperty(extruder_setting, "value"))
if extruder_str_nr == "-1": if extruder_str_nr == "-1":
extruder_str_nr = self._application.getMachineManager().defaultExtruderPosition extruder_str_nr = self._application.getMachineManager().defaultExtruderPosition
if extruder_str_nr in self.extruderIds: if extruder_str_nr in self.extruderIds:
@ -285,8 +296,11 @@ class ExtruderManager(QObject):
global_stack = application.getGlobalContainerStack() global_stack = application.getGlobalContainerStack()
# Starts with the adhesion extruder. # Starts with the adhesion extruder.
if global_stack.getProperty("adhesion_type", "value") != "none": adhesion_type = global_stack.getProperty("adhesion_type", "value")
return global_stack.getProperty("adhesion_extruder_nr", "value") if adhesion_type in {"skirt", "brim"}:
return global_stack.getProperty("skirt_brim_extruder_nr", "value")
if adhesion_type == "raft":
return global_stack.getProperty("raft_base_extruder_nr", "value")
# No adhesion? Well maybe there is still support brim. # No adhesion? Well maybe there is still support brim.
if (global_stack.getProperty("support_enable", "value") or global_stack.getProperty("support_structure", "value") == "tree") and global_stack.getProperty("support_brim_enable", "value"): if (global_stack.getProperty("support_enable", "value") or global_stack.getProperty("support_structure", "value") == "tree") and global_stack.getProperty("support_brim_enable", "value"):
@ -403,6 +417,35 @@ class ExtruderManager(QObject):
raise IndexError(msg) raise IndexError(msg)
extruder_stack_0.definition = extruder_definition extruder_stack_0.definition = extruder_definition
@pyqtSlot("QVariant", result = bool)
def getExtruderHasQualityForMaterial(self, extruder_stack: "ExtruderStack") -> bool:
"""Checks if quality nodes exist for the variant/material combination."""
application = cura.CuraApplication.CuraApplication.getInstance()
global_stack = application.getGlobalContainerStack()
if not global_stack or not extruder_stack:
return False
if not global_stack.getMetaDataEntry("has_materials"):
return True
machine_node = ContainerTree.getInstance().machines[global_stack.definition.getId()]
active_variant_name = extruder_stack.variant.getMetaDataEntry("name")
if active_variant_name not in machine_node.variants:
Logger.log("w", "Could not find the variant %s", active_variant_name)
return True
active_variant_node = machine_node.variants[active_variant_name]
try:
active_material_node = active_variant_node.materials[extruder_stack.material.getMetaDataEntry("base_file")]
except KeyError: # The material in this stack is not a supported material (e.g. wrong filament diameter, as loaded from a project file).
return False
active_material_node_qualities = active_material_node.qualities
if not active_material_node_qualities:
return False
return list(active_material_node_qualities.keys())[0] != "empty_quality"
@pyqtSlot(str, result="QVariant") @pyqtSlot(str, result="QVariant")
def getInstanceExtruderValues(self, key: str) -> List: def getInstanceExtruderValues(self, key: str) -> List:
"""Get all extruder values for a certain setting. """Get all extruder values for a certain setting.

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. # Cura is released under the terms of the LGPLv3 or higher.
import time import time
@ -627,7 +627,7 @@ class MachineManager(QObject):
return "" return ""
return global_container_stack.getIntentCategory() 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) @pyqtProperty("QStringList", notify=activeIntentChanged)
def extruderPositionsWithNonActiveIntent(self): def extruderPositionsWithNonActiveIntent(self):
global_container_stack = self._application.getGlobalContainerStack() global_container_stack = self._application.getGlobalContainerStack()
@ -855,7 +855,6 @@ class MachineManager(QObject):
caution_message = Message( caution_message = Message(
catalog.i18nc("@info:message Followed by a list of settings.", 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)), "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")) title = catalog.i18nc("@info:title", "Settings updated"))
caution_message.show() caution_message.show()
@ -1191,7 +1190,7 @@ class MachineManager(QObject):
self.setIntentByCategory(quality_changes_group.intent_category) self.setIntentByCategory(quality_changes_group.intent_category)
self._reCalculateNumUserSettings() self._reCalculateNumUserSettings()
self.correctExtruderSettings()
self.activeQualityGroupChanged.emit() self.activeQualityGroupChanged.emit()
self.activeQualityChangesGroupChanged.emit() self.activeQualityChangesGroupChanged.emit()
@ -1398,6 +1397,8 @@ class MachineManager(QObject):
# previous one). # previous one).
self._global_container_stack.setUserChanges(global_user_changes) self._global_container_stack.setUserChanges(global_user_changes)
for i, user_changes in enumerate(per_extruder_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]) self._global_container_stack.extruderList[i].setUserChanges(per_extruder_user_changes[i])
@pyqtSlot(QObject) @pyqtSlot(QObject)
@ -1534,7 +1535,7 @@ class MachineManager(QObject):
machine_node = ContainerTree.getInstance().machines.get(machine_definition_id) machine_node = ContainerTree.getInstance().machines.get(machine_definition_id)
variant_node = machine_node.variants.get(variant_name) variant_node = machine_node.variants.get(variant_name)
if variant_node is None: if variant_node is None:
Logger.error("There is no variant with the name {variant_name}.") Logger.error(f"There is no variant with the name {variant_name}.")
return return
self.setVariant(position, variant_node) self.setVariant(position, variant_node)

View file

@ -61,6 +61,10 @@ class SettingInheritanceManager(QObject):
result.append(key) result.append(key)
return result return result
@pyqtSlot(str, str, result = bool)
def hasOverrides(self, key: str, extruder_index: str):
return key in self.getOverridesForExtruder(key, extruder_index)
@pyqtSlot(str, str, result = "QStringList") @pyqtSlot(str, str, result = "QStringList")
def getOverridesForExtruder(self, key: str, extruder_index: str) -> List[str]: def getOverridesForExtruder(self, key: str, extruder_index: str) -> List[str]:
if self._global_container_stack is None: if self._global_container_stack is None:

View file

@ -18,6 +18,8 @@ class SingleInstance:
self._single_instance_server = None 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 # 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. # exists. Returns True if the single instance server is found, otherwise False.
def startClient(self) -> bool: def startClient(self) -> bool:
@ -42,8 +44,9 @@ class SingleInstance:
# "command" field is required and holds the name of the command to execute. # "command" field is required and holds the name of the command to execute.
# Other fields depend on the command. # Other fields depend on the command.
payload = {"command": "clear-all"} if self._application.getPreferences().getValue("cura/single_instance_clear_before_load"):
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii")) payload = {"command": "clear-all"}
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii"))
payload = {"command": "focus"} payload = {"command": "focus"}
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii")) 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.") Logger.log("e", "Single instance server was not created.")
def _onClientConnected(self) -> None: 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] connection = None #type: Optional[QLocalSocket]
if self._single_instance_server: if self._single_instance_server:
connection = self._single_instance_server.nextPendingConnection() connection = self._single_instance_server.nextPendingConnection()

View file

@ -3,6 +3,7 @@
import numpy import numpy
from PyQt5 import QtCore from PyQt5 import QtCore
from PyQt5.QtCore import QCoreApplication
from PyQt5.QtGui import QImage from PyQt5.QtGui import QImage
from cura.PreviewPass import PreviewPass from cura.PreviewPass import PreviewPass
@ -46,6 +47,7 @@ class Snapshot:
render_width, render_height = (width, height) if active_camera is None else active_camera.getWindowSize() render_width, render_height = (width, height) if active_camera is None else active_camera.getWindowSize()
render_width = int(render_width) render_width = int(render_width)
render_height = int(render_height) render_height = int(render_height)
QCoreApplication.processEvents() # This ensures that the opengl context is correctly available
preview_pass = PreviewPass(render_width, render_height) preview_pass = PreviewPass(render_width, render_height)
root = scene.getRoot() root = scene.getRoot()

View file

@ -56,8 +56,8 @@ class OnExitCallbackManager:
self._application.callLater(self._application.closeApplication) 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 # 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 # "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 quiting, it will call the next # 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. # registered on-exit callback if available.
def onCurrentCallbackFinished(self, should_proceed: bool = True) -> None: def onCurrentCallbackFinished(self, should_proceed: bool = True) -> None:
if not should_proceed: if not should_proceed:

View file

@ -17,7 +17,9 @@ class CuraSplashScreen(QSplashScreen):
self._scale = 0.7 self._scale = 0.7
self._version_y_offset = 0 # when extra visual elements are in the background image, move version text down self._version_y_offset = 0 # when extra visual elements are in the background image, move version text down
if ApplicationMetadata.IsEnterpriseVersion: if ApplicationMetadata.IsAlternateVersion:
splash_image = QPixmap(Resources.getPath(Resources.Images, "cura_wip.png"))
elif ApplicationMetadata.IsEnterpriseVersion:
splash_image = QPixmap(Resources.getPath(Resources.Images, "cura_enterprise.png")) splash_image = QPixmap(Resources.getPath(Resources.Images, "cura_enterprise.png"))
self._version_y_offset = 26 self._version_y_offset = 26
else: else:
@ -70,7 +72,7 @@ class CuraSplashScreen(QSplashScreen):
font = QFont() # Using system-default font here font = QFont() # Using system-default font here
font.setPixelSize(18) font.setPixelSize(18)
painter.setFont(font) painter.setFont(font)
painter.drawText(60, 70 + self._version_y_offset, round(330 * self._scale), round(230 * self._scale), Qt.AlignLeft | Qt.AlignTop, version[0]) painter.drawText(60, 70 + self._version_y_offset, round(330 * self._scale), round(230 * self._scale), Qt.AlignLeft | Qt.AlignTop, version[0] if not ApplicationMetadata.IsAlternateVersion else ApplicationMetadata.CuraBuildType)
if len(version) > 1: if len(version) > 1:
font.setPixelSize(16) font.setPixelSize(16)
painter.setFont(font) painter.setFont(font)

View file

@ -90,7 +90,7 @@ class ObjectsModel(ListModel):
parent = node.getParent() parent = node.getParent()
if parent and parent.callDecoration("isGroup"): 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") 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: if Application.getInstance().getPreferences().getValue("view/filter_current_build_plate") and node_build_plate_number != self._build_plate_number:

View file

@ -13,6 +13,8 @@ from UM.Qt.Duration import Duration
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from UM.MimeTypeDatabase import MimeTypeDatabase, MimeTypeNotFoundError from UM.MimeTypeDatabase import MimeTypeDatabase, MimeTypeNotFoundError
from UM.OutputDevice.OutputDevice import OutputDevice
from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice
if TYPE_CHECKING: if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
@ -68,6 +70,7 @@ class PrintInformation(QObject):
self._application.globalContainerStackChanged.connect(self.setToZeroPrintInformation) self._application.globalContainerStackChanged.connect(self.setToZeroPrintInformation)
self._application.fileLoaded.connect(self.setBaseName) self._application.fileLoaded.connect(self.setBaseName)
self._application.workspaceLoaded.connect(self.setProjectName) self._application.workspaceLoaded.connect(self.setProjectName)
self._application.getOutputDeviceManager().writeStarted.connect(self._onOutputStart)
self._application.getMachineManager().rootMaterialChanged.connect(self._onActiveMaterialsChanged) self._application.getMachineManager().rootMaterialChanged.connect(self._onActiveMaterialsChanged)
self._application.getInstance().getPreferences().preferenceChanged.connect(self._onPreferencesChanged) 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""" """Listen to scene changes to check if we need to reset the print information"""
self.setToZeroPrintInformation(self._active_build_plate) 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

@ -46,7 +46,9 @@ class TextManager(QObject):
line = line.replace("[", "") line = line.replace("[", "")
line = line.replace("]", "") line = line.replace("]", "")
open_version = Version(line) 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 if open_version < Version([0, 0, 1]): # Something went wrong with parsing, assume non-numerical alternate version that should be on top.
open_version = Version([99, 99, 99])
if Version([14, 99, 99]) < open_version < Version([16, 0, 0]): # 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_version = Version([0, open_version.getMinor(), open_version.getRevision(), open_version.getPostfixVersion()])
open_header = "" open_header = ""
change_logs_dict[open_version] = collections.OrderedDict() change_logs_dict[open_version] = collections.OrderedDict()
@ -66,7 +68,9 @@ class TextManager(QObject):
text_version = version text_version = version
if version < Version([1, 0, 0]): # Bit of a hack: We released the 15.x.x versions before 2.x if version < Version([1, 0, 0]): # Bit of a hack: We released the 15.x.x versions before 2.x
text_version = Version([15, version.getMinor(), version.getRevision(), version.getPostfixVersion()]) text_version = Version([15, version.getMinor(), version.getRevision(), version.getPostfixVersion()])
content += "<h1>" + str(text_version) + "</h1><br>" if version > Version([99, 0, 0]): # Leave it out altogether if it was originally a non-numbered version.
text_version = ""
content += ("<h1>" + str(text_version) + "</h1><br>") if text_version else ""
content += "" content += ""
for change in change_logs_dict[version]: for change in change_logs_dict[version]:
if str(change) != "": if str(change) != "":

View file

@ -1,7 +1,9 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from collections import deque
import os import os
from collections import deque
from typing import TYPE_CHECKING, Optional, List, Dict, Any from typing import TYPE_CHECKING, Optional, List, Dict, Any
from PyQt5.QtCore import QUrl, Qt, pyqtSlot, pyqtProperty, pyqtSignal from PyQt5.QtCore import QUrl, Qt, pyqtSlot, pyqtProperty, pyqtSignal
@ -16,24 +18,23 @@ if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
#
# This is the Qt ListModel that contains all welcome pages data. Each page is a page that can be shown as a step in the
# welcome wizard dialog. Each item in this ListModel represents a page, which contains the following fields:
#
# - id : A unique page_id which can be used in function goToPage(page_id)
# - page_url : The QUrl to the QML file that contains the content of this page
# - next_page_id : (OPTIONAL) The next page ID to go to when this page finished. This is optional. If this is not
# provided, it will go to the page with the current index + 1
# - next_page_button_text: (OPTIONAL) The text to show for the "next" button, by default it's the translated text of
# "Next". Note that each step QML can decide whether to use this text or not, so it's not
# mandatory.
# - should_show_function : (OPTIONAL) An optional function that returns True/False indicating if this page should be
# shown. By default all pages should be shown. If a function returns False, that page will
# be skipped and its next page will be shown.
#
# Note that in any case, a page that has its "should_show_function" == False will ALWAYS be skipped.
#
class WelcomePagesModel(ListModel): class WelcomePagesModel(ListModel):
"""
This is the Qt ListModel that contains all welcome pages data. Each page is a page that can be shown as a step in
the welcome wizard dialog. Each item in this ListModel represents a page, which contains the following fields:
- id : A unique page_id which can be used in function goToPage(page_id)
- page_url : The QUrl to the QML file that contains the content of this page
- next_page_id : (OPTIONAL) The next page ID to go to when this page finished. This is optional. If this is
not provided, it will go to the page with the current index + 1
- next_page_button_text : (OPTIONAL) The text to show for the "next" button, by default it's the translated text of
"Next". Note that each step QML can decide whether to use this text or not, so it's not
mandatory.
- should_show_function : (OPTIONAL) An optional function that returns True/False indicating if this page should be
shown. By default all pages should be shown. If a function returns False, that page will
be skipped and its next page will be shown.
Note that in any case, a page that has its "should_show_function" == False will ALWAYS be skipped.
"""
IdRole = Qt.UserRole + 1 # Page ID IdRole = Qt.UserRole + 1 # Page ID
PageUrlRole = Qt.UserRole + 2 # URL to the page's QML file PageUrlRole = Qt.UserRole + 2 # URL to the page's QML file
@ -55,11 +56,11 @@ class WelcomePagesModel(ListModel):
self._default_next_button_text = self._catalog.i18nc("@action:button", "Next") self._default_next_button_text = self._catalog.i18nc("@action:button", "Next")
self._pages = [] # type: List[Dict[str, Any]] self._pages: List[Dict[str, Any]] = []
self._current_page_index = 0 self._current_page_index = 0
# Store all the previous page indices so it can go back. # Store all the previous page indices so it can go back.
self._previous_page_indices_stack = deque() # type: deque self._previous_page_indices_stack: deque = deque()
# If the welcome flow should be shown. It can show the complete flow or just the changelog depending on the # If the welcome flow should be shown. It can show the complete flow or just the changelog depending on the
# specific case. See initialize() for how this variable is set. # specific case. See initialize() for how this variable is set.
@ -72,17 +73,21 @@ class WelcomePagesModel(ListModel):
def currentPageIndex(self) -> int: def currentPageIndex(self) -> int:
return self._current_page_index return self._current_page_index
# Returns a float number in [0, 1] which indicates the current progress.
@pyqtProperty(float, notify = currentPageIndexChanged) @pyqtProperty(float, notify = currentPageIndexChanged)
def currentProgress(self) -> float: def currentProgress(self) -> float:
"""
Returns a float number in [0, 1] which indicates the current progress.
"""
if len(self._items) == 0: if len(self._items) == 0:
return 0 return 0
else: else:
return self._current_page_index / len(self._items) return self._current_page_index / len(self._items)
# Indicates if the current page is the last page.
@pyqtProperty(bool, notify = currentPageIndexChanged) @pyqtProperty(bool, notify = currentPageIndexChanged)
def isCurrentPageLast(self) -> bool: def isCurrentPageLast(self) -> bool:
"""
Indicates if the current page is the last page.
"""
return self._current_page_index == len(self._items) - 1 return self._current_page_index == len(self._items) - 1
def _setCurrentPageIndex(self, page_index: int) -> None: def _setCurrentPageIndex(self, page_index: int) -> None:
@ -91,17 +96,22 @@ class WelcomePagesModel(ListModel):
self._current_page_index = page_index self._current_page_index = page_index
self.currentPageIndexChanged.emit() self.currentPageIndexChanged.emit()
# Ends the Welcome-Pages. Put as a separate function for cases like the 'decline' in the User-Agreement.
@pyqtSlot() @pyqtSlot()
def atEnd(self) -> None: def atEnd(self) -> None:
"""
Ends the Welcome-Pages. Put as a separate function for cases like the 'decline' in the User-Agreement.
"""
self.allFinished.emit() self.allFinished.emit()
self.resetState() self.resetState()
# Goes to the next page.
# If "from_index" is given, it will look for the next page to show starting from the "from_index" page instead of
# the "self._current_page_index".
@pyqtSlot() @pyqtSlot()
def goToNextPage(self, from_index: Optional[int] = None) -> None: def goToNextPage(self, from_index: Optional[int] = None) -> None:
"""
Goes to the next page.
If "from_index" is given, it will look for the next page to show starting from the "from_index" page instead of
the "self._current_page_index".
"""
# Look for the next page that should be shown # Look for the next page that should be shown
current_index = self._current_page_index if from_index is None else from_index current_index = self._current_page_index if from_index is None else from_index
while True: while True:
@ -137,9 +147,11 @@ class WelcomePagesModel(ListModel):
# Move to the next page # Move to the next page
self._setCurrentPageIndex(next_page_index) self._setCurrentPageIndex(next_page_index)
# Goes to the previous page. If there's no previous page, do nothing.
@pyqtSlot() @pyqtSlot()
def goToPreviousPage(self) -> None: def goToPreviousPage(self) -> None:
"""
Goes to the previous page. If there's no previous page, do nothing.
"""
if len(self._previous_page_indices_stack) == 0: if len(self._previous_page_indices_stack) == 0:
Logger.log("i", "No previous page, do nothing") Logger.log("i", "No previous page, do nothing")
return return
@ -148,9 +160,9 @@ class WelcomePagesModel(ListModel):
self._current_page_index = previous_page_index self._current_page_index = previous_page_index
self.currentPageIndexChanged.emit() self.currentPageIndexChanged.emit()
# Sets the current page to the given page ID. If the page ID is not found, do nothing.
@pyqtSlot(str) @pyqtSlot(str)
def goToPage(self, page_id: str) -> None: def goToPage(self, page_id: str) -> None:
"""Sets the current page to the given page ID. If the page ID is not found, do nothing."""
page_index = self.getPageIndexById(page_id) page_index = self.getPageIndexById(page_id)
if page_index is None: if page_index is None:
# FIXME: If we cannot find the next page, we cannot do anything here. # FIXME: If we cannot find the next page, we cannot do anything here.
@ -165,18 +177,22 @@ class WelcomePagesModel(ListModel):
# Find the next page to show starting from the "page_index" # Find the next page to show starting from the "page_index"
self.goToNextPage(from_index = page_index) self.goToNextPage(from_index = page_index)
# Checks if the page with the given index should be shown by calling the "should_show_function" associated with it.
# If the function is not present, returns True (show page by default).
def _shouldPageBeShown(self, page_index: int) -> bool: def _shouldPageBeShown(self, page_index: int) -> bool:
"""
Checks if the page with the given index should be shown by calling the "should_show_function" associated with
it. If the function is not present, returns True (show page by default).
"""
next_page_item = self.getItem(page_index) next_page_item = self.getItem(page_index)
should_show_function = next_page_item.get("should_show_function", lambda: True) should_show_function = next_page_item.get("should_show_function", lambda: True)
return should_show_function() return should_show_function()
# Resets the state of the WelcomePagesModel. This functions does the following:
# - Resets current_page_index to 0
# - Clears the previous page indices stack
@pyqtSlot() @pyqtSlot()
def resetState(self) -> None: def resetState(self) -> None:
"""
Resets the state of the WelcomePagesModel. This functions does the following:
- Resets current_page_index to 0
- Clears the previous page indices stack
"""
self._current_page_index = 0 self._current_page_index = 0
self._previous_page_indices_stack.clear() self._previous_page_indices_stack.clear()
@ -188,8 +204,8 @@ class WelcomePagesModel(ListModel):
def shouldShowWelcomeFlow(self) -> bool: def shouldShowWelcomeFlow(self) -> bool:
return self._should_show_welcome_flow return self._should_show_welcome_flow
# Gets the page index with the given page ID. If the page ID doesn't exist, returns None.
def getPageIndexById(self, page_id: str) -> Optional[int]: def getPageIndexById(self, page_id: str) -> Optional[int]:
"""Gets the page index with the given page ID. If the page ID doesn't exist, returns None."""
page_idx = None page_idx = None
for idx, page_item in enumerate(self._items): for idx, page_item in enumerate(self._items):
if page_item["id"] == page_id: if page_item["id"] == page_id:
@ -197,8 +213,9 @@ class WelcomePagesModel(ListModel):
break break
return page_idx return page_idx
# Convenience function to get QUrl path to pages that's located in "resources/qml/WelcomePages". @staticmethod
def _getBuiltinWelcomePagePath(self, page_filename: str) -> "QUrl": def _getBuiltinWelcomePagePath(page_filename: str) -> QUrl:
"""Convenience function to get QUrl path to pages that's located in "resources/qml/WelcomePages"."""
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
return QUrl.fromLocalFile(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles, return QUrl.fromLocalFile(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles,
os.path.join("WelcomePages", page_filename))) os.path.join("WelcomePages", page_filename)))
@ -213,21 +230,22 @@ class WelcomePagesModel(ListModel):
self._initialize() self._initialize()
def _initialize(self, update_should_show_flag: bool = True) -> None: def _initialize(self, update_should_show_flag: bool = True) -> None:
show_whatsnew_only = False show_whats_new_only = False
if update_should_show_flag: if update_should_show_flag:
has_active_machine = self._application.getMachineManager().activeMachine is not None has_active_machine = self._application.getMachineManager().activeMachine is not None
has_app_just_upgraded = self._application.hasJustUpdatedFromOldVersion() has_app_just_upgraded = self._application.hasJustUpdatedFromOldVersion()
# Only show the what's new dialog if there's no machine and we have just upgraded # Only show the what's new dialog if there's no machine and we have just upgraded
show_complete_flow = not has_active_machine show_complete_flow = not has_active_machine
show_whatsnew_only = has_active_machine and has_app_just_upgraded show_whats_new_only = has_active_machine and has_app_just_upgraded
# FIXME: This is a hack. Because of the circular dependency between MachineManager, ExtruderManager, and # FIXME: This is a hack. Because of the circular dependency between MachineManager, ExtruderManager, and
# possibly some others, setting the initial active machine is not done when the MachineManager gets initialized. # possibly some others, setting the initial active machine is not done when the MachineManager gets
# So at this point, we don't know if there will be an active machine or not. It could be that the active machine # initialized. So at this point, we don't know if there will be an active machine or not. It could be that
# files are corrupted so we cannot rely on Preferences either. This makes sure that once the active machine # the active machine files are corrupted so we cannot rely on Preferences either. This makes sure that once
# gets changed, this model updates the flags, so it can decide whether to show the welcome flow or not. # the active machine gets changed, this model updates the flags, so it can decide whether to show the
should_show_welcome_flow = show_complete_flow or show_whatsnew_only # welcome flow or not.
should_show_welcome_flow = show_complete_flow or show_whats_new_only
if should_show_welcome_flow != self._should_show_welcome_flow: if should_show_welcome_flow != self._should_show_welcome_flow:
self._should_show_welcome_flow = should_show_welcome_flow self._should_show_welcome_flow = should_show_welcome_flow
self.shouldShowWelcomeFlowChanged.emit() self.shouldShowWelcomeFlowChanged.emit()
@ -274,23 +292,25 @@ class WelcomePagesModel(ListModel):
] ]
pages_to_show = all_pages_list pages_to_show = all_pages_list
if show_whatsnew_only: if show_whats_new_only:
pages_to_show = list(filter(lambda x: x["id"] == "whats_new", all_pages_list)) pages_to_show = list(filter(lambda x: x["id"] == "whats_new", all_pages_list))
self._pages = pages_to_show self._pages = pages_to_show
self.setItems(self._pages) self.setItems(self._pages)
# For convenience, inject the default "next" button text to each item if it's not present.
def setItems(self, items: List[Dict[str, Any]]) -> None: def setItems(self, items: List[Dict[str, Any]]) -> None:
# For convenience, inject the default "next" button text to each item if it's not present.
for item in items: for item in items:
if "next_page_button_text" not in item: if "next_page_button_text" not in item:
item["next_page_button_text"] = self._default_next_button_text item["next_page_button_text"] = self._default_next_button_text
super().setItems(items) super().setItems(items)
# Indicates if the machine action panel should be shown by checking if there's any first start machine actions
# available.
def shouldShowMachineActions(self) -> bool: def shouldShowMachineActions(self) -> bool:
"""
Indicates if the machine action panel should be shown by checking if there's any first start machine actions
available.
"""
global_stack = self._application.getMachineManager().activeMachine global_stack = self._application.getMachineManager().activeMachine
if global_stack is None: if global_stack is None:
return False return False
@ -312,6 +332,3 @@ class WelcomePagesModel(ListModel):
def addPage(self) -> None: def addPage(self) -> None:
pass pass
__all__ = ["WelcomePagesModel"]

View file

@ -1,27 +1,39 @@
# Copyright (c) 2021 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from .WelcomePagesModel import WelcomePagesModel
import os import os
from typing import Optional, Dict, List, Tuple from typing import Optional, Dict, List, Tuple, TYPE_CHECKING
from PyQt5.QtCore import pyqtProperty, pyqtSlot from PyQt5.QtCore import pyqtProperty, pyqtSlot
from UM.Logger import Logger from UM.Logger import Logger
from UM.Resources import Resources from UM.Resources import Resources
# from cura.UI.WelcomePagesModel import WelcomePagesModel
# This Qt ListModel is more or less the same the WelcomePagesModel, except that this model is only for showing the
# "what's new" page. This is also used in the "Help" menu to show the changes log. if TYPE_CHECKING:
# from PyQt5.QtCore import QObject
from cura.CuraApplication import CuraApplication
class WhatsNewPagesModel(WelcomePagesModel): class WhatsNewPagesModel(WelcomePagesModel):
"""
This Qt ListModel is more or less the same the WelcomePagesModel, except that this model is only for showing the
"what's new" page. This is also used in the "Help" menu to show the changes log.
"""
image_formats = [".png", ".jpg", ".jpeg", ".gif", ".svg"] image_formats = [".png", ".jpg", ".jpeg", ".gif", ".svg"]
text_formats = [".txt", ".htm", ".html"] text_formats = [".txt", ".htm", ".html"]
image_key = "image" image_key = "image"
text_key = "text" text_key = "text"
def __init__(self, application: "CuraApplication", parent: Optional["QObject"] = None) -> None:
super().__init__(application, parent)
self._subpages: List[Dict[str, Optional[str]]] = []
@staticmethod @staticmethod
def _collectOrdinalFiles(resource_type: int, include: List[str]) -> Tuple[Dict[int, str], int]: def _collectOrdinalFiles(resource_type: int, include: List[str]) -> Tuple[Dict[int, str], int]:
result = {} #type: Dict[int, str] result = {} # type: Dict[int, str]
highest = -1 highest = -1
try: try:
folder_path = Resources.getPath(resource_type, "whats_new") folder_path = Resources.getPath(resource_type, "whats_new")
@ -65,7 +77,7 @@ class WhatsNewPagesModel(WelcomePagesModel):
texts, max_text = WhatsNewPagesModel._collectOrdinalFiles(Resources.Texts, WhatsNewPagesModel.text_formats) texts, max_text = WhatsNewPagesModel._collectOrdinalFiles(Resources.Texts, WhatsNewPagesModel.text_formats)
highest = max(max_image, max_text) highest = max(max_image, max_text)
self._subpages = [] #type: List[Dict[str, Optional[str]]] self._subpages = []
for n in range(0, highest + 1): for n in range(0, highest + 1):
self._subpages.append({ self._subpages.append({
WhatsNewPagesModel.image_key: None if n not in images else images[n], WhatsNewPagesModel.image_key: None if n not in images else images[n],
@ -93,5 +105,3 @@ class WhatsNewPagesModel(WelcomePagesModel):
def getSubpageText(self, page: int) -> str: def getSubpageText(self, page: int) -> str:
result = self._getSubpageItem(page, WhatsNewPagesModel.text_key) result = self._getSubpageItem(page, WhatsNewPagesModel.text_key)
return result if result else "* * *" return result if result else "* * *"
__all__ = ["WhatsNewPagesModel"]

View file

@ -0,0 +1,218 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl
from PyQt5.QtGui import QDesktopServices
from typing import Dict, Optional, TYPE_CHECKING
import zipfile # To export all materials in a .zip archive.
import cura.CuraApplication # Imported like this to prevent circular imports.
from UM.Resources import Resources
from cura.PrinterOutput.UploadMaterialsJob import UploadMaterialsJob, UploadMaterialsError # To export materials to the output printer.
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
from UM.i18n import i18nCatalog
from UM.Logger import Logger
from UM.Message import Message
if TYPE_CHECKING:
from UM.Signal import Signal
catalog = i18nCatalog("cura")
class CloudMaterialSync(QObject):
"""
Handles the synchronisation of material profiles with cloud accounts.
"""
def __init__(self, parent: QObject = None):
super().__init__(parent)
self.sync_all_dialog = None # type: Optional[QObject]
self._export_upload_status = "idle"
self._checkIfNewMaterialsWereInstalled()
self._export_progress = 0.0
self._printer_status = {} # type: Dict[str, str]
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 openSyncAllWindow(self):
self.reset()
if self.sync_all_dialog is None:
qml_path = Resources.getPath(cura.CuraApplication.CuraApplication.ResourceTypes.QmlFiles, "Preferences",
"Materials", "MaterialsSyncDialog.qml")
self.sync_all_dialog = cura.CuraApplication.CuraApplication.getInstance().createQmlComponent(
qml_path, {})
if self.sync_all_dialog is None: # Failed to load QML file.
return
self.sync_all_dialog.setProperty("syncModel", self)
self.sync_all_dialog.setProperty("pageIndex", 0) # Return to first page.
self.sync_all_dialog.setProperty("hasExportedUsb", False) # If the user exported USB before, reset that page.
self.sync_all_dialog.setProperty("syncStatusText", "") # Reset any previous error messages.
self.sync_all_dialog.show()
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"),
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":
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(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, notify_progress: Optional["Signal"] = None) -> None:
"""
Export all materials to a certain file path.
:param file_path: The path to export the materials to.
"""
registry = CuraContainerRegistry.getInstance()
# Create empty archive.
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
materials_metadata = registry.findInstanceContainersMetadata(type = "material")
for index, metadata in enumerate(materials_metadata):
if notify_progress is not None:
progress = index / len(materials_metadata)
notify_progress.emit(progress)
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}.")
exportUploadStatusChanged = pyqtSignal()
@pyqtProperty(str, notify = exportUploadStatusChanged)
def exportUploadStatus(self) -> str:
return self._export_upload_status
@pyqtSlot()
def exportUpload(self) -> None:
"""
Export all materials and upload them to the user's account.
"""
self._export_upload_status = "uploading"
self.exportUploadStatusChanged.emit()
job = UploadMaterialsJob(self)
job.uploadProgressChanged.connect(self._onUploadProgressChanged)
job.uploadCompleted.connect(self.exportUploadCompleted)
job.start()
def _onUploadProgressChanged(self, progress: float, printers_status: Dict[str, str]):
self.setExportProgress(progress)
self.setPrinterStatus(printers_status)
def exportUploadCompleted(self, job_result: UploadMaterialsJob.Result, job_error: Optional[Exception]):
if not self.sync_all_dialog: # Shouldn't get triggered before the dialog is open, but better to check anyway.
return
if job_result == UploadMaterialsJob.Result.FAILED:
if isinstance(job_error, UploadMaterialsError):
self.sync_all_dialog.setProperty("syncStatusText", str(job_error))
else: # Could be "None"
self.sync_all_dialog.setProperty("syncStatusText", catalog.i18nc("@text", "Unknown error."))
self._export_upload_status = "error"
else:
self._export_upload_status = "success"
self.exportUploadStatusChanged.emit()
exportProgressChanged = pyqtSignal(float)
def setExportProgress(self, progress: float) -> None:
self._export_progress = progress
self.exportProgressChanged.emit(self._export_progress)
@pyqtProperty(float, fset = setExportProgress, notify = exportProgressChanged)
def exportProgress(self) -> float:
return self._export_progress
printerStatusChanged = pyqtSignal()
def setPrinterStatus(self, new_status: Dict[str, str]) -> None:
self._printer_status = new_status
self.printerStatusChanged.emit()
@pyqtProperty("QVariantMap", fset = setPrinterStatus, notify = printerStatusChanged)
def printerStatus(self) -> Dict[str, str]:
return self._printer_status
def reset(self) -> None:
self.setPrinterStatus({})
self.setExportProgress(0.0)
self._export_upload_status = "idle"
self.exportUploadStatusChanged.emit()

View file

@ -13,6 +13,9 @@ DEFAULT_DIGITAL_FACTORY_URL = "https://digitalfactory.ultimaker.com" # type: st
META_UM_LINKED_TO_ACCOUNT = "um_linked_to_account" META_UM_LINKED_TO_ACCOUNT = "um_linked_to_account"
"""(bool) Whether a cloud printer is linked to an Ultimaker account""" """(bool) Whether a cloud printer is linked to an Ultimaker account"""
META_CAPABILITIES = "capabilities"
"""(list[str]) a list of capabilities this printer supports"""
try: try:
from cura.CuraVersion import CuraCloudAPIRoot # type: ignore from cura.CuraVersion import CuraCloudAPIRoot # type: ignore
if CuraCloudAPIRoot == "": if CuraCloudAPIRoot == "":

View file

@ -1,9 +1,15 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtNetwork import QNetworkRequest from PyQt5.QtNetwork import QNetworkRequest
from UM.Logger import Logger from UM.Logger import Logger
from UM.TaskManagement.HttpRequestScope import DefaultUserAgentScope from UM.TaskManagement.HttpRequestScope import DefaultUserAgentScope
from cura.API import Account
from cura.CuraApplication import CuraApplication from typing import TYPE_CHECKING
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
from cura.API.Account import Account
class UltimakerCloudScope(DefaultUserAgentScope): class UltimakerCloudScope(DefaultUserAgentScope):
@ -12,7 +18,7 @@ class UltimakerCloudScope(DefaultUserAgentScope):
Also add the user agent headers (see DefaultUserAgentScope). Also add the user agent headers (see DefaultUserAgentScope).
""" """
def __init__(self, application: CuraApplication): def __init__(self, application: "CuraApplication"):
super().__init__(application) super().__init__(application)
api = application.getCuraAPI() api = application.getCuraAPI()
self._account = api.account # type: Account self._account = api.account # type: Account

View file

@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# Copyright (c) 2020 Ultimaker B.V. # Copyright (c) 2022 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
# Remove the working directory from sys.path. # Remove the working directory from sys.path.
@ -15,6 +15,9 @@ if "" in sys.path:
import argparse import argparse
import faulthandler import faulthandler
import os import os
if sys.platform != "linux": # Turns out the Linux build _does_ use this, but we're not making an Enterprise release for that system anyway.
os.environ["QT_PLUGIN_PATH"] = "" # Security workaround: Don't need it, and introduces an attack vector, so set to nul.
os.environ["QML2_IMPORT_PATH"] = "" # Security workaround: Don't need it, and introduces an attack vector, so set to nul.
from PyQt5.QtNetwork import QSslConfiguration, QSslSocket from PyQt5.QtNetwork import QSslConfiguration, QSslSocket
@ -48,6 +51,8 @@ if with_sentry_sdk:
sentry_env = "development" # Master is always a development version. sentry_env = "development" # Master is always a development version.
elif "beta" in ApplicationMetadata.CuraVersion or "BETA" in ApplicationMetadata.CuraVersion: elif "beta" in ApplicationMetadata.CuraVersion or "BETA" in ApplicationMetadata.CuraVersion:
sentry_env = "beta" sentry_env = "beta"
elif "alpha" in ApplicationMetadata.CuraVersion or "ALPHA" in ApplicationMetadata.CuraVersion:
sentry_env = "alpha"
try: try:
if ApplicationMetadata.CuraVersion.split(".")[2] == "99": if ApplicationMetadata.CuraVersion.split(".")[2] == "99":
sentry_env = "nightly" sentry_env = "nightly"

View file

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

View file

@ -1,3 +1,3 @@
#!/usr/bin/env bash #!/usr/bin/env bash
cd build cd build
ctest3 -j4 --output-on-failure -T Test ctest -j4 --output-on-failure -T Test

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

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. 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 Disallowed areas
---- ----

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before After
Before After

View file

@ -49,7 +49,9 @@ _ignored_machine_network_metadata = {
"removal_warning", "removal_warning",
"group_name", "group_name",
"group_size", "group_size",
"connection_type" "connection_type",
"capabilities",
"octoprint_api_key",
} # type: Set[str] } # type: Set[str]
@ -377,7 +379,9 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
# - the global stack DOESN'T exist but some/all of the extruder stacks exist # - the global stack DOESN'T exist but some/all of the extruder stacks exist
# To simplify this, only check if the global stack exists or not # To simplify this, only check if the global stack exists or not
global_stack_id = self._stripFileToId(global_stack_file) global_stack_id = self._stripFileToId(global_stack_file)
serialized = archive.open(global_stack_file).read().decode("utf-8") serialized = archive.open(global_stack_file).read().decode("utf-8")
serialized = GlobalStack._updateSerialized(serialized, global_stack_file) serialized = GlobalStack._updateSerialized(serialized, global_stack_file)
machine_name = self._getMachineNameFromSerializedStack(serialized) machine_name = self._getMachineNameFromSerializedStack(serialized)
self._machine_info.metadata_dict = self._getMetaDataDictFromSerializedStack(serialized) self._machine_info.metadata_dict = self._getMetaDataDictFromSerializedStack(serialized)

View file

@ -6,7 +6,7 @@ import QtQuick.Controls 2.3
import QtQuick.Layouts 1.3 import QtQuick.Layouts 1.3
import QtQuick.Window 2.2 import QtQuick.Window 2.2
import UM 1.1 as UM import UM 1.5 as UM
import Cura 1.1 as Cura import Cura 1.1 as Cura
UM.Dialog UM.Dialog
@ -19,9 +19,7 @@ UM.Dialog
width: minimumWidth width: minimumWidth
height: Math.max(dialogSummaryItem.height + 2 * buttonsItem.height, minimumHeight) // 2 * button height to also have some extra space around the button relative to the button size height: Math.max(dialogSummaryItem.height + 2 * buttonsItem.height, minimumHeight) // 2 * button height to also have some extra space around the button relative to the button size
property int comboboxHeight: 15 * screenScaleFactor property int comboboxHeight: UM.Theme.getSize("default_margin").height
property int spacerHeight: 10 * screenScaleFactor
property int doubleSpacerHeight: 20 * screenScaleFactor
onClosing: manager.notifyClosed() onClosing: manager.notifyClosed()
onVisibleChanged: onVisibleChanged:
@ -46,10 +44,6 @@ UM.Dialog
id: catalog id: catalog
name: "cura" name: "cura"
} }
SystemPalette
{
id: palette
}
ListModel ListModel
{ {
@ -68,45 +62,39 @@ UM.Dialog
{ {
width: parent.width width: parent.width
height: childrenRect.height height: childrenRect.height
spacing: 2 * screenScaleFactor spacing: UM.Theme.getSize("default_margin").height
Label
Column
{ {
id: titleLabel
text: catalog.i18nc("@action:title", "Summary - Cura Project")
font.pointSize: 18
}
Rectangle
{
id: separator
color: palette.text
width: parent.width width: parent.width
height: 1 height: cildrenRect.height
}
Item // Spacer UM.Label
{ {
height: doubleSpacerHeight id: titleLabel
width: height text: catalog.i18nc("@action:title", "Summary - Cura Project")
font: UM.Theme.getFont("large")
}
Rectangle
{
id: separator
color: UM.Theme.getColor("text")
width: parent.width
height: UM.Theme.getSize("default_lining").height
}
} }
Row Item
{ {
height: childrenRect.height
width: parent.width width: parent.width
Label height: childrenRect.height
{
text: catalog.i18nc("@action:label", "Printer settings")
font.bold: true
width: (parent.width / 3) | 0
}
Item
{
// spacer
height: spacerHeight
width: (parent.width / 3) | 0
}
UM.TooltipArea UM.TooltipArea
{ {
id: machineResolveStrategyTooltip id: machineResolveStrategyTooltip
anchors.top: parent.top
anchors.right: parent.right
width: (parent.width / 3) | 0 width: (parent.width / 3) | 0
height: visible ? comboboxHeight : 0 height: visible ? comboboxHeight : 0
visible: base.visible && machineResolveComboBox.model.count > 1 visible: base.visible && machineResolveComboBox.model.count > 1
@ -157,64 +145,65 @@ UM.Dialog
} }
} }
} }
}
Row Column
{
width: parent.width
height: childrenRect.height
Label
{ {
text: catalog.i18nc("@action:label", "Type") width: parent.width
width: (parent.width / 3) | 0 height: cildrenRect.height
}
Label UM.Label
{ {
text: manager.machineType id: printer_settings_label
width: (parent.width / 3) | 0 text: catalog.i18nc("@action:label", "Printer settings")
font: UM.Theme.getFont("default_bold")
}
Row
{
width: parent.width
height: childrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Type")
width: (parent.width / 3) | 0
}
UM.Label
{
text: manager.machineType
width: (parent.width / 3) | 0
}
}
Row
{
width: parent.width
height: childrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", manager.isPrinterGroup ? "Printer Group" : "Printer Name")
width: (parent.width / 3) | 0
}
UM.Label
{
text: manager.machineName
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
} }
} }
Row Item
{ {
width: parent.width width: parent.width
height: childrenRect.height height: childrenRect.height
Label
{
text: catalog.i18nc("@action:label", manager.isPrinterGroup ? "Printer Group" : "Printer Name")
width: (parent.width / 3) | 0
}
Label
{
text: manager.machineName
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
Item // Spacer
{
height: doubleSpacerHeight
width: height
}
Row
{
height: childrenRect.height
width: parent.width
Label
{
text: catalog.i18nc("@action:label", "Profile settings")
font.bold: true
width: (parent.width / 3) | 0
}
Item
{
// spacer
height: spacerHeight
width: (parent.width / 3) | 0
}
UM.TooltipArea UM.TooltipArea
{ {
id: qualityChangesResolveTooltip anchors.right: parent.right
anchors.top: parent.top
width: (parent.width / 3) | 0 width: (parent.width / 3) | 0
height: visible ? comboboxHeight : 0 height: visible ? comboboxHeight : 0
visible: manager.qualityChangesConflict visible: manager.qualityChangesConflict
@ -232,96 +221,105 @@ UM.Dialog
} }
} }
} }
Column
{
width: parent.width
height: cildrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Profile settings")
font: UM.Theme.getFont("default_bold")
}
Row
{
width: parent.width
height: childrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Name")
width: (parent.width / 3) | 0
}
UM.Label
{
text: manager.qualityName
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
Row
{
width: parent.width
height: childrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Intent")
width: (parent.width / 3) | 0
}
UM.Label
{
text: manager.intentName
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
Row
{
width: parent.width
height: childrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Not in profile")
visible: manager.numUserSettings != 0
width: (parent.width / 3) | 0
}
UM.Label
{
text: catalog.i18ncp("@action:label", "%1 override", "%1 overrides", manager.numUserSettings).arg(manager.numUserSettings)
visible: manager.numUserSettings != 0
width: (parent.width / 3) | 0
}
}
Row
{
width: parent.width
height: childrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Derivative from")
visible: manager.numSettingsOverridenByQualityChanges != 0
width: (parent.width / 3) | 0
}
UM.Label
{
text: catalog.i18ncp("@action:label", "%1, %2 override", "%1, %2 overrides", manager.numSettingsOverridenByQualityChanges).arg(manager.qualityType).arg(manager.numSettingsOverridenByQualityChanges)
width: (parent.width / 3) | 0
visible: manager.numSettingsOverridenByQualityChanges != 0
wrapMode: Text.WordWrap
}
}
}
} }
Row
Item
{ {
width: parent.width width: parent.width
height: childrenRect.height height: childrenRect.height
Label
{
text: catalog.i18nc("@action:label", "Name")
width: (parent.width / 3) | 0
}
Label
{
text: manager.qualityName
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
Row
{
width: parent.width
height: childrenRect.height
Label
{
text: catalog.i18nc("@action:label", "Intent")
width: (parent.width / 3) | 0
}
Label
{
text: manager.intentName
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
Row
{
width: parent.width
height: manager.numUserSettings != 0 ? childrenRect.height : 0
Label
{
text: catalog.i18nc("@action:label", "Not in profile")
width: (parent.width / 3) | 0
}
Label
{
text: catalog.i18ncp("@action:label", "%1 override", "%1 overrides", manager.numUserSettings).arg(manager.numUserSettings)
width: (parent.width / 3) | 0
}
visible: manager.numUserSettings != 0
}
Row
{
width: parent.width
height: manager.numSettingsOverridenByQualityChanges != 0 ? childrenRect.height : 0
Label
{
text: catalog.i18nc("@action:label", "Derivative from")
width: (parent.width / 3) | 0
}
Label
{
text: catalog.i18ncp("@action:label", "%1, %2 override", "%1, %2 overrides", manager.numSettingsOverridenByQualityChanges).arg(manager.qualityType).arg(manager.numSettingsOverridenByQualityChanges)
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
visible: manager.numSettingsOverridenByQualityChanges != 0
}
Item // Spacer
{
height: doubleSpacerHeight
width: height
}
Row
{
height: childrenRect.height
width: parent.width
Label
{
text: catalog.i18nc("@action:label", "Material settings")
font.bold: true
width: (parent.width / 3) | 0
}
Item
{
// spacer
height: spacerHeight
width: (parent.width / 3) | 0
}
UM.TooltipArea UM.TooltipArea
{ {
id: materialResolveTooltip id: materialResolveTooltip
anchors.right: parent.right
anchors.top: parent.top
width: (parent.width / 3) | 0 width: (parent.width / 3) | 0
height: visible ? comboboxHeight : 0 height: visible ? comboboxHeight : 0
visible: manager.materialConflict visible: manager.materialConflict
@ -339,76 +337,91 @@ UM.Dialog
} }
} }
} }
Column
{
width: parent.width
height: cildrenRect.height
Row
{
height: childrenRect.height
width: parent.width
spacing: UM.Theme.getSize("narrow_margin").width
UM.Label
{
text: catalog.i18nc("@action:label", "Material settings")
font: UM.Theme.getFont("default_bold")
width: (parent.width / 3) | 0
}
}
Repeater
{
model: manager.materialLabels
delegate: Row
{
width: parent.width
height: childrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Name")
width: (parent.width / 3) | 0
}
UM.Label
{
text: modelData
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
}
}
} }
Repeater Column
{ {
model: manager.materialLabels width: parent.width
delegate: Row height: cildrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Setting visibility")
font: UM.Theme.getFont("default_bold")
}
Row
{ {
width: parent.width width: parent.width
height: childrenRect.height height: childrenRect.height
Label UM.Label
{ {
text: catalog.i18nc("@action:label", "Name") text: catalog.i18nc("@action:label", "Mode")
width: (parent.width / 3) | 0 width: (parent.width / 3) | 0
} }
Label UM.Label
{ {
text: modelData text: manager.activeMode
width: (parent.width / 3) | 0
}
}
Row
{
width: parent.width
height: childrenRect.height
visible: manager.hasVisibleSettingsField
UM.Label
{
text: catalog.i18nc("@action:label", "Visible settings:")
width: (parent.width / 3) | 0
}
UM.Label
{
text: catalog.i18nc("@action:label", "%1 out of %2" ).arg(manager.numVisibleSettings).arg(manager.totalNumberOfSettings)
width: (parent.width / 3) | 0 width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
} }
} }
} }
Item // Spacer
{
height: doubleSpacerHeight
width: height
}
Label
{
text: catalog.i18nc("@action:label", "Setting visibility")
font.bold: true
}
Row
{
width: parent.width
height: childrenRect.height
Label
{
text: catalog.i18nc("@action:label", "Mode")
width: (parent.width / 3) | 0
}
Label
{
text: manager.activeMode
width: (parent.width / 3) | 0
}
}
Row
{
width: parent.width
height: childrenRect.height
visible: manager.hasVisibleSettingsField
Label
{
text: catalog.i18nc("@action:label", "Visible settings:")
width: (parent.width / 3) | 0
}
Label
{
text: catalog.i18nc("@action:label", "%1 out of %2" ).arg(manager.numVisibleSettings).arg(manager.totalNumberOfSettings)
width: (parent.width / 3) | 0
}
}
Item // Spacer
{
height: spacerHeight
width: height
}
Row Row
{ {
width: parent.width width: parent.width
@ -418,12 +431,10 @@ UM.Dialog
{ {
width: warningLabel.height width: warningLabel.height
height: width height: width
source: UM.Theme.getIcon("Information") source: UM.Theme.getIcon("Information")
color: palette.text color: UM.Theme.getColor("text")
} }
Label UM.Label
{ {
id: warningLabel id: warningLabel
text: catalog.i18nc("@action:warning", "Loading a project will clear all models on the build plate.") text: catalog.i18nc("@action:warning", "Loading a project will clear all models on the build plate.")
@ -432,44 +443,22 @@ UM.Dialog
} }
} }
} }
Item
{ buttonSpacing: UM.Theme.getSize("default_margin").width
id: buttonsItem
width: parent.width rightButtons: [
height: childrenRect.height Cura.TertiaryButton
anchors.bottom: parent.bottom
anchors.right: parent.right
Button
{ {
id: cancel_button text: catalog.i18nc("@action:button", "Cancel")
text: catalog.i18nc("@action:button","Cancel"); onClicked: reject()
onClicked: { manager.onCancelButtonClicked() } },
enabled: true Cura.PrimaryButton
anchors.bottom: parent.bottom
anchors.right: ok_button.left
anchors.rightMargin: 2 * screenScaleFactor
}
Button
{ {
id: ok_button text: catalog.i18nc("@action:button", "Open")
anchors.right: parent.right onClicked: accept()
anchors.bottom: parent.bottom
text: catalog.i18nc("@action:button","Open");
onClicked: { manager.closeBackend(); manager.onOkButtonClicked() }
} }
} ]
onRejected: manager.onCancelButtonClicked()
function accept() { onAccepted: manager.onOkButtonClicked()
manager.closeBackend();
manager.onOkButtonClicked();
base.visible = false;
base.accept();
}
function reject() {
manager.onCancelButtonClicked();
base.visible = false;
base.rejected();
}
} }

View file

@ -32,6 +32,12 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
Logger.error("3MF Writer class is unavailable. Can't write workspace.") Logger.error("3MF Writer class is unavailable. Can't write workspace.")
return False return False
global_stack = machine_manager.activeMachine
if global_stack is None:
self.setInformation(catalog.i18nc("@error", "There is no workspace yet to write. Please add a printer first."))
Logger.error("Tried to write a 3MF workspace before there was a global stack.")
return False
# Indicate that the 3mf mesh writer should not close the archive just yet (we still need to add stuff to it). # Indicate that the 3mf mesh writer should not close the archive just yet (we still need to add stuff to it).
mesh_writer.setStoreArchive(True) mesh_writer.setStoreArchive(True)
mesh_writer.write(stream, nodes, mode) mesh_writer.write(stream, nodes, mode)
@ -40,7 +46,6 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
if archive is None: # This happens if there was no mesh data to write. if archive is None: # This happens if there was no mesh data to write.
archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED) archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED)
global_stack = machine_manager.activeMachine
try: try:
# Add global container stack data to the archive. # Add global container stack data to the archive.
@ -149,7 +154,8 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
"group_name", "group_name",
"group_size", "group_size",
"connection_type", "connection_type",
"octoprint_api_key" "capabilities",
"octoprint_api_key",
} }
serialized_data = container.serialize(ignored_metadata_keys = ignore_keys) serialized_data = container.serialize(ignored_metadata_keys = ignore_keys)

View file

@ -10,6 +10,10 @@ from UM.Application import Application
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from cura.Utils.Threading import call_on_qt_thread
from cura.Snapshot import Snapshot
from PyQt5.QtCore import QBuffer
import Savitar import Savitar
@ -149,6 +153,22 @@ class ThreeMFWriter(MeshWriter):
relations_element = ET.Element("Relationships", xmlns = self._namespaces["relationships"]) relations_element = ET.Element("Relationships", xmlns = self._namespaces["relationships"])
model_relation_element = ET.SubElement(relations_element, "Relationship", Target = "/3D/3dmodel.model", Id = "rel0", Type = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel") model_relation_element = ET.SubElement(relations_element, "Relationship", Target = "/3D/3dmodel.model", Id = "rel0", Type = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel")
# Attempt to add a thumbnail
snapshot = self._createSnapshot()
if snapshot:
thumbnail_buffer = QBuffer()
thumbnail_buffer.open(QBuffer.ReadWrite)
snapshot.save(thumbnail_buffer, "PNG")
thumbnail_file = zipfile.ZipInfo("Metadata/thumbnail.png")
# Don't try to compress snapshot file, because the PNG is pretty much as compact as it will get
archive.writestr(thumbnail_file, thumbnail_buffer.data())
# Add PNG to content types file
thumbnail_type = ET.SubElement(content_types, "Default", Extension = "png", ContentType = "image/png")
# Add thumbnail relation to _rels/.rels file
thumbnail_relation_element = ET.SubElement(relations_element, "Relationship", Target = "/Metadata/thumbnail.png", Id = "rel1", Type = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail")
savitar_scene = Savitar.Scene() savitar_scene = Savitar.Scene()
metadata_to_store = CuraApplication.getInstance().getController().getScene().getMetaData() metadata_to_store = CuraApplication.getInstance().getController().getScene().getMetaData()
@ -212,3 +232,17 @@ class ThreeMFWriter(MeshWriter):
self._archive = archive self._archive = archive
return True return True
@call_on_qt_thread # must be called from the main thread because of OpenGL
def _createSnapshot(self):
Logger.log("d", "Creating thumbnail image...")
if not CuraApplication.getInstance().isVisible:
Logger.log("w", "Can't create snapshot when renderer not initialized.")
return None
try:
snapshot = Snapshot.snapshot(width = 300, height = 300)
except:
Logger.logException("w", "Failed to create snapshot image")
return None
return snapshot

View file

@ -1,39 +1,34 @@
// Copyright (c) 2018 Ultimaker B.V. // Copyright (c) 2022 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher. // Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7 import QtQuick 2.7
import QtQuick.Controls 2.2 import QtQuick.Controls 2.2
import QtQuick.Layouts 1.3 import QtQuick.Layouts 1.3
import UM 1.1 as UM import UM 1.5 as UM
ScrollView ListView
{ {
property alias model: backupList.model
width: parent.width
clip: true clip: true
ListView ScrollBar.vertical: UM.ScrollBar {}
delegate: Item
{ {
id: backupList // Add a margin, otherwise the scrollbar is on top of the right most component
width: parent.width width: parent.width - UM.Theme.getSize("scrollbar").width
delegate: Item height: childrenRect.height
BackupListItem
{ {
// Add a margin, otherwise the scrollbar is on top of the right most component id: backupListItem
width: parent.width - UM.Theme.getSize("default_margin").width width: parent.width
height: childrenRect.height }
BackupListItem Rectangle
{ {
id: backupListItem id: divider
width: parent.width color: UM.Theme.getColor("lining")
} height: UM.Theme.getSize("default_lining").height
Rectangle
{
id: divider
color: UM.Theme.getColor("lining")
height: UM.Theme.getSize("default_lining").height
}
} }
} }
} }

View file

@ -5,7 +5,7 @@ import QtQuick 2.7
import QtQuick.Controls 2.1 import QtQuick.Controls 2.1
import QtQuick.Layouts 1.3 import QtQuick.Layouts 1.3
import UM 1.3 as UM import UM 1.5 as UM
import Cura 1.0 as Cura import Cura 1.0 as Cura
import "../components" import "../components"
@ -35,7 +35,7 @@ RowLayout
busy: CuraDrive.isCreatingBackup busy: CuraDrive.isCreatingBackup
} }
Cura.CheckBoxWithTooltip UM.CheckBox
{ {
id: autoBackupEnabled id: autoBackupEnabled
checked: CuraDrive.autoBackupEnabled checked: CuraDrive.autoBackupEnabled

View file

@ -1,12 +1,11 @@
// Copyright (c) 2018 Ultimaker B.V. // Copyright (c) 2022 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher. // Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7 import QtQuick 2.7
import QtQuick.Controls 2.1 import QtQuick.Controls 2.1
import QtQuick.Layouts 1.3 import QtQuick.Layouts 1.3
import QtQuick.Dialogs 1.1
import UM 1.1 as UM import UM 1.5 as UM
import Cura 1.0 as Cura import Cura 1.0 as Cura
Item Item
@ -42,28 +41,22 @@ Item
onClicked: backupListItem.showDetails = !backupListItem.showDetails onClicked: backupListItem.showDetails = !backupListItem.showDetails
} }
Label UM.Label
{ {
text: new Date(modelData.generated_time).toLocaleString(UM.Preferences.getValue("general/language")) text: new Date(modelData.generated_time).toLocaleString(UM.Preferences.getValue("general/language"))
color: UM.Theme.getColor("text")
elide: Text.ElideRight elide: Text.ElideRight
Layout.minimumWidth: 100 * screenScaleFactor Layout.minimumWidth: 100 * screenScaleFactor
Layout.maximumWidth: 500 * screenScaleFactor Layout.maximumWidth: 500 * screenScaleFactor
Layout.fillWidth: true Layout.fillWidth: true
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
} }
Label UM.Label
{ {
text: modelData.metadata.description text: modelData.metadata.description
color: UM.Theme.getColor("text")
elide: Text.ElideRight elide: Text.ElideRight
Layout.minimumWidth: 100 * screenScaleFactor Layout.minimumWidth: 100 * screenScaleFactor
Layout.maximumWidth: 500 * screenScaleFactor Layout.maximumWidth: 500 * screenScaleFactor
Layout.fillWidth: true Layout.fillWidth: true
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
} }
Cura.SecondaryButton Cura.SecondaryButton
@ -94,21 +87,21 @@ Item
anchors.top: dataRow.bottom anchors.top: dataRow.bottom
} }
MessageDialog Cura.MessageDialog
{ {
id: confirmDeleteDialog id: confirmDeleteDialog
title: catalog.i18nc("@dialog:title", "Delete Backup") title: catalog.i18nc("@dialog:title", "Delete Backup")
text: catalog.i18nc("@dialog:info", "Are you sure you want to delete this backup? This cannot be undone.") text: catalog.i18nc("@dialog:info", "Are you sure you want to delete this backup? This cannot be undone.")
standardButtons: StandardButton.Yes | StandardButton.No standardButtons: Dialog.Yes | Dialog.No
onYes: CuraDrive.deleteBackup(modelData.backup_id) onAccepted: CuraDrive.deleteBackup(modelData.backup_id)
} }
MessageDialog Cura.MessageDialog
{ {
id: confirmRestoreDialog id: confirmRestoreDialog
title: catalog.i18nc("@dialog:title", "Restore Backup") title: catalog.i18nc("@dialog:title", "Restore Backup")
text: catalog.i18nc("@dialog:info", "You will need to restart Cura before your backup is restored. Do you want to close Cura now?") text: catalog.i18nc("@dialog:info", "You will need to restart Cura before your backup is restored. Do you want to close Cura now?")
standardButtons: StandardButton.Yes | StandardButton.No standardButtons: Dialog.Yes | Dialog.No
onYes: CuraDrive.restoreBackup(modelData.backup_id) onAccepted: CuraDrive.restoreBackup(modelData.backup_id)
} }
} }

View file

@ -5,7 +5,7 @@ import QtQuick 2.7
import QtQuick.Controls 2.1 import QtQuick.Controls 2.1
import QtQuick.Layouts 1.3 import QtQuick.Layouts 1.3
import UM 1.3 as UM import UM 1.5 as UM
RowLayout RowLayout
{ {
@ -26,27 +26,21 @@ RowLayout
color: UM.Theme.getColor("text") color: UM.Theme.getColor("text")
} }
Label UM.Label
{ {
id: detailName id: detailName
color: UM.Theme.getColor("text")
elide: Text.ElideRight elide: Text.ElideRight
Layout.minimumWidth: 50 * screenScaleFactor Layout.minimumWidth: 50 * screenScaleFactor
Layout.maximumWidth: 100 * screenScaleFactor Layout.maximumWidth: 100 * screenScaleFactor
Layout.fillWidth: true Layout.fillWidth: true
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
} }
Label UM.Label
{ {
id: detailValue id: detailValue
color: UM.Theme.getColor("text")
elide: Text.ElideRight elide: Text.ElideRight
Layout.minimumWidth: 50 * screenScaleFactor Layout.minimumWidth: 50 * screenScaleFactor
Layout.maximumWidth: 100 * screenScaleFactor Layout.maximumWidth: 100 * screenScaleFactor
Layout.fillWidth: true Layout.fillWidth: true
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
} }
} }

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View file

@ -5,7 +5,7 @@ import QtQuick 2.7
import QtQuick.Controls 2.1 import QtQuick.Controls 2.1
import QtQuick.Window 2.2 import QtQuick.Window 2.2
import UM 1.3 as UM import UM 1.5 as UM
import Cura 1.1 as Cura import Cura 1.1 as Cura
import "../components" import "../components"
@ -23,23 +23,19 @@ Column
{ {
id: profileImage id: profileImage
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
source: "../images/icon.png" source: "../images/backup.svg"
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
width: Math.round(parent.width / 4) width: Math.round(parent.width / 4)
} }
Label UM.Label
{ {
id: welcomeTextLabel id: welcomeTextLabel
text: catalog.i18nc("@description", "Backup and synchronize your Cura settings.") text: catalog.i18nc("@description", "Backup and synchronize your Cura settings.")
width: Math.round(parent.width / 2) width: Math.round(parent.width / 2)
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("text")
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
wrapMode: Label.WordWrap wrapMode: Label.WordWrap
renderType: Text.NativeRendering
} }
Cura.PrimaryButton Cura.PrimaryButton

View file

@ -159,7 +159,8 @@ class CuraEngineBackend(QObject, Backend):
self._slicing_error_message = Message( self._slicing_error_message = Message(
text = catalog.i18nc("@message", "Slicing failed with an unexpected error. Please consider reporting a bug on our issue tracker."), 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") title = catalog.i18nc("@message:title", "Slicing failed"),
message_type = Message.MessageType.ERROR
) )
self._slicing_error_message.addAction( self._slicing_error_message.addAction(
action_id = "report_bug", action_id = "report_bug",
@ -427,6 +428,7 @@ class CuraEngineBackend(QObject, Backend):
"Unable to slice with the current settings. The following settings have errors: {0}").format(", ".join(error_labels)), "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"), title = catalog.i18nc("@info:title", "Unable to slice"),
message_type = Message.MessageType.WARNING) 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._error_message.show()
self.setState(BackendState.Error) self.setState(BackendState.Error)
self.backendError.emit(job) self.backendError.emit(job)
@ -453,6 +455,7 @@ class CuraEngineBackend(QObject, Backend):
"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())), "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"), title = catalog.i18nc("@info:title", "Unable to slice"),
message_type = Message.MessageType.WARNING) 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._error_message.show()
self.setState(BackendState.Error) self.setState(BackendState.Error)
self.backendError.emit(job) self.backendError.emit(job)
@ -467,6 +470,7 @@ class CuraEngineBackend(QObject, Backend):
self._error_message.show() self._error_message.show()
self.setState(BackendState.Error) self.setState(BackendState.Error)
self.backendError.emit(job) self.backendError.emit(job)
return
else: else:
self.setState(BackendState.NotStarted) self.setState(BackendState.NotStarted)
@ -645,7 +649,7 @@ class CuraEngineBackend(QObject, Backend):
for node in DepthFirstIterator(self._scene.getRoot()): for node in DepthFirstIterator(self._scene.getRoot()):
if node.callDecoration("getLayerData"): if node.callDecoration("getLayerData"):
if not build_plate_numbers or node.callDecoration("getBuildPlateNumber") in build_plate_numbers: 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) cast(SceneNode, node.getParent()).removeChild(node)
def markSliceAll(self) -> None: def markSliceAll(self) -> None:

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. # Cura is released under the terms of the LGPLv3 or higher.
import numpy import numpy
@ -123,6 +123,9 @@ class StartSliceJob(Job):
Job.yieldThread() Job.yieldThread()
for changed_setting_key in changed_setting_keys: for changed_setting_key in changed_setting_keys:
if not stack.getProperty(changed_setting_key, "enabled"):
continue
validation_state = stack.getProperty(changed_setting_key, "validationState") validation_state = stack.getProperty(changed_setting_key, "validationState")
if validation_state is None: if validation_state is None:
@ -195,13 +198,20 @@ class StartSliceJob(Job):
# Remove old layer data. # Remove old layer data.
for node in DepthFirstIterator(self._scene.getRoot()): for node in DepthFirstIterator(self._scene.getRoot()):
if node.callDecoration("getLayerData") and node.callDecoration("getBuildPlateNumber") == self._build_plate_number: 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) cast(SceneNode, node.getParent()).removeChild(node)
break break
# Get the objects in their groups to print. # Get the objects in their groups to print.
object_groups = [] object_groups = []
if stack.getProperty("print_sequence", "value") == "one_at_a_time": if stack.getProperty("print_sequence", "value") == "one_at_a_time":
modifier_mesh_nodes = []
for node in DepthFirstIterator(self._scene.getRoot()):
build_plate_number = node.callDecoration("getBuildPlateNumber")
if node.callDecoration("isNonPrintingMesh") and build_plate_number == self._build_plate_number:
modifier_mesh_nodes.append(node)
for node in OneAtATimeIterator(self._scene.getRoot()): for node in OneAtATimeIterator(self._scene.getRoot()):
temp_list = [] temp_list = []
@ -218,7 +228,7 @@ class StartSliceJob(Job):
temp_list.append(child_node) temp_list.append(child_node)
if temp_list: if temp_list:
object_groups.append(temp_list) object_groups.append(temp_list + modifier_mesh_nodes)
Job.yieldThread() Job.yieldThread()
if len(object_groups) == 0: if len(object_groups) == 0:
Logger.log("w", "No objects suitable for one at a time found, or no correct order found") Logger.log("w", "No objects suitable for one at a time found, or no correct order found")
@ -353,10 +363,19 @@ class StartSliceJob(Job):
result[key] = stack.getProperty(key, "value") result[key] = stack.getProperty(key, "value")
Job.yieldThread() 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["print_temperature"] = result["material_print_temperature"]
result["travel_speed"] = result["speed_travel"] 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["date"] = time.strftime("%d-%m-%Y")
result["day"] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][int(time.strftime("%w"))] result["day"] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][int(time.strftime("%w"))]
result["initial_extruder_nr"] = CuraApplication.getInstance().getExtruderManager().getInitialExtruderNr() result["initial_extruder_nr"] = CuraApplication.getInstance().getExtruderManager().getInitialExtruderNr()
@ -455,9 +474,9 @@ class StartSliceJob(Job):
bed_temperature_settings = ["material_bed_temperature", "material_bed_temperature_layer_0"] 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} 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 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} 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. # 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 # Use values from the first used extruder by default so we get the expected temperatures

View file

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

View file

@ -1,10 +1,9 @@
// Copyright (C) 2021 Ultimaker B.V. //Copyright (C) 2022 Ultimaker B.V.
//Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10 import QtQuick 2.10
import QtQuick.Window 2.2 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 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM import UM 1.2 as UM
import Cura 1.6 as Cura import Cura 1.6 as Cura

View file

@ -1,10 +1,9 @@
// Copyright (C) 2021 Ultimaker B.V. //Copyright (C) 2022 Ultimaker B.V.
//Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10 import QtQuick 2.10
import QtQuick.Window 2.2 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 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM import UM 1.2 as UM
import Cura 1.6 as Cura import Cura 1.6 as Cura

View file

@ -1,10 +1,9 @@
// Copyright (C) 2021 Ultimaker B.V. //Copyright (C) 2022 Ultimaker B.V.
//Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10 import QtQuick 2.10
import QtQuick.Window 2.2 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 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM import UM 1.2 as UM
import Cura 1.6 as Cura import Cura 1.6 as Cura

View file

@ -1,10 +1,10 @@
// Copyright (C) 2021 Ultimaker B.V. //Copyright (C) 2022 Ultimaker B.V.
//Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10 import Qt.labs.qmlmodels 1.0
import QtQuick 2.15
import QtQuick.Window 2.2 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 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM import UM 1.2 as UM
import Cura 1.6 as Cura import Cura 1.6 as Cura
@ -57,52 +57,32 @@ Item
border.width: UM.Theme.getSize("default_lining").width border.width: UM.Theme.getSize("default_lining").width
border.color: UM.Theme.getColor("lining") border.color: UM.Theme.getColor("lining")
//We can't use Cura's TableView here, since in Cura >= 5.0 this uses QtQuick.TableView, while in Cura < 5.0 this uses QtControls1.TableView.
Cura.TableView //So we have to define our own. Once support for 4.13 and earlier is dropped, we can switch to Cura.TableView.
Table
{ {
id: filesTableView id: filesTableView
anchors.fill: parent anchors.fill: parent
model: manager.digitalFactoryFileModel anchors.margins: parent.border.width
visible: model.count != 0 && manager.retrievingFileStatus != DF.RetrievalStatus.InProgress
selectionMode: OldControls.SelectionMode.SingleSelection columnHeaders: ["Name", "Uploaded by", "Uploaded at"]
onDoubleClicked: model: TableModel
{
TableModelColumn { display: "fileName" }
TableModelColumn { display: "username" }
TableModelColumn { display: "uploadedAt" }
rows: manager.digitalFactoryFileModel.items
}
onCurrentRowChanged:
{
manager.setSelectedFileIndices([currentRow]);
}
onDoubleClicked: function(row)
{ {
manager.setSelectedFileIndices([row]); manager.setSelectedFileIndices([row]);
openFilesButton.clicked(); 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 Label
@ -161,7 +141,6 @@ Item
{ {
// Make sure no files are selected when the file model changes // Make sure no files are selected when the file model changes
filesTableView.currentRow = -1 filesTableView.currentRow = -1
filesTableView.selection.clear()
} }
} }
} }
@ -187,7 +166,7 @@ Item
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.right: parent.right anchors.right: parent.right
text: "Open" text: "Open"
enabled: filesTableView.selection.count > 0 enabled: filesTableView.currentRow >= 0
onClicked: onClicked:
{ {
manager.openSelectedFiles() manager.openSelectedFiles()

View file

@ -44,7 +44,7 @@ Cura.RoundedRectangle
{ {
id: projectImage id: projectImage
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: UM.Theme.getSize("toolbox_thumbnail_small").width width: UM.Theme.getSize("card_icon").width
height: Math.round(width * 3/4) height: Math.round(width * 3/4)
sourceSize.width: width sourceSize.width: width
sourceSize.height: height sourceSize.height: height

View file

@ -1,12 +1,12 @@
// Copyright (C) 2021 Ultimaker B.V. //Copyright (C) 2022 Ultimaker B.V.
//Cura is released under the terms of the LGPLv3 or higher.
import Qt.labs.qmlmodels 1.0
import QtQuick 2.10 import QtQuick 2.10
import QtQuick.Window 2.2 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 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM import UM 1.5 as UM
import Cura 1.6 as Cura import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF import DigitalFactory 1.0 as DF
@ -86,35 +86,22 @@ Item
border.width: UM.Theme.getSize("default_lining").width border.width: UM.Theme.getSize("default_lining").width
border.color: UM.Theme.getColor("lining") border.color: UM.Theme.getColor("lining")
//We can't use Cura's TableView here, since in Cura >= 5.0 this uses QtQuick.TableView, while in Cura < 5.0 this uses QtControls1.TableView.
Cura.TableView //So we have to define our own. Once support for 4.13 and earlier is dropped, we can switch to Cura.TableView.
Table
{ {
id: filesTableView id: filesTableView
anchors.fill: parent anchors.fill: parent
model: manager.digitalFactoryFileModel anchors.margins: parent.border.width
visible: model.count != 0 && manager.retrievingFileStatus != DF.RetrievalStatus.InProgress
selectionMode: OldControls.SelectionMode.NoSelection
OldControls.TableViewColumn allowSelection: false
columnHeaders: ["Name", "Uploaded by", "Uploaded at"]
model: TableModel
{ {
id: fileNameColumn TableModelColumn { display: "fileName" }
role: "fileName" TableModelColumn { display: "username" }
title: "@tableViewColumn:title", "Name" TableModelColumn { display: "uploadedAt" }
width: Math.round(filesTableView.width / 3) rows: manager.digitalFactoryFileModel.items
}
OldControls.TableViewColumn
{
id: usernameColumn
role: "username"
title: "Uploaded by"
width: Math.round(filesTableView.width / 3)
}
OldControls.TableViewColumn
{
role: "uploadedAt"
title: "Uploaded at"
} }
} }
@ -173,8 +160,7 @@ Item
function onItemsChanged() function onItemsChanged()
{ {
// Make sure no files are selected when the file model changes // Make sure no files are selected when the file model changes
filesTableView.currentRow = -1 filesTableView.currentRow = -1;
filesTableView.selection.clear()
} }
} }
} }
@ -200,7 +186,7 @@ Item
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.right: parent.right anchors.right: parent.right
text: "Save" text: "Save"
enabled: (asProjectCheckbox.checked || asSlicedCheckbox.checked) && dfFilenameTextfield.text.length >= 1 enabled: (asProjectCheckbox.checked || asSlicedCheckbox.checked) && dfFilenameTextfield.text.length >= 1 && dfFilenameTextfield.state !== 'invalid'
onClicked: onClicked:
{ {
@ -228,7 +214,7 @@ Item
width: childrenRect.width width: childrenRect.width
spacing: UM.Theme.getSize("default_margin").width spacing: UM.Theme.getSize("default_margin").width
Cura.CheckBox UM.CheckBox
{ {
id: asProjectCheckbox id: asProjectCheckbox
height: UM.Theme.getSize("checkbox").height height: UM.Theme.getSize("checkbox").height
@ -238,7 +224,7 @@ Item
font: UM.Theme.getFont("medium") font: UM.Theme.getFont("medium")
} }
Cura.CheckBox UM.CheckBox
{ {
id: asSlicedCheckbox id: asSlicedCheckbox
height: UM.Theme.getSize("checkbox").height height: UM.Theme.getSize("checkbox").height

View file

@ -1,15 +1,13 @@
// Copyright (C) 2021 Ultimaker B.V. //Copyright (C) 2022 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher. //Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10 import QtQuick 2.10
import QtQuick.Window 2.2 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 2.3
import QtQuick.Controls.Styles 1.4
import QtQuick.Layouts 1.1 import QtQuick.Layouts 1.1
import UM 1.2 as UM import UM 1.2 as UM
import Cura 1.6 as Cura import Cura 1.7 as Cura
import DigitalFactory 1.0 as DF import DigitalFactory 1.0 as DF
@ -44,16 +42,13 @@ Item
height: childrenRect.height height: childrenRect.height
spacing: UM.Theme.getSize("default_margin").width spacing: UM.Theme.getSize("default_margin").width
Cura.TextField Cura.SearchBar
{ {
id: searchBar id: searchBar
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: createNewProjectButton.height implicitHeight: createNewProjectButton.height
focus: true
onTextEdited: manager.projectFilter = text //Update the search filter when editing this text field. onTextEdited: manager.projectFilter = text //Update the search filter when editing this text field.
leftIcon: UM.Theme.getIcon("Magnifier")
placeholderText: "Search"
} }
Cura.SecondaryButton Cura.SecondaryButton
@ -76,12 +71,11 @@ Item
id: upgradePlanButton id: upgradePlanButton
text: "Upgrade plan" text: "Upgrade plan"
iconSource: UM.Theme.getIcon("LinkExternal") iconSource: UM.Theme.getIcon("external_link")
visible: createNewProjectButtonVisible && !manager.userAccountCanCreateNewLibraryProject && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed) visible: createNewProjectButtonVisible && !manager.userAccountCanCreateNewLibraryProject && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
tooltip: "You have reached the maximum number of projects allowed by your subscription. Please upgrade to the Professional subscription to create more projects." tooltip: "Maximum number of projects reached. Please upgrade your subscription to create more projects."
tooltipWidth: parent.width * 0.5
onClicked: Qt.openUrlExternally("https://ultimaker.com/software/ultimaker-essentials/sign-up-cura?utm_source=cura&utm_medium=software&utm_campaign=lib-max") onClicked: Qt.openUrlExternally("https://ultimaker.com/software/enterprise-software?utm_source=cura&utm_medium=software&utm_campaign=MaxProjLink")
} }
} }
@ -124,7 +118,7 @@ Item
id: visitDigitalLibraryButton id: visitDigitalLibraryButton
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
text: "Visit Digital Library" text: "Visit Digital Library"
onClicked: Qt.openUrlExternally(CuraApplication.ultimakerDigitalFactoryUrl + "/app/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. visible: searchBar.text === "" //Show the link to Digital Library when there are no projects in the user's Library.
} }
} }
@ -206,7 +200,7 @@ Item
LoadMoreProjectsCard LoadMoreProjectsCard
{ {
id: loadMoreProjectsCard id: loadMoreProjectsCard
height: UM.Theme.getSize("toolbox_thumbnail_small").height height: UM.Theme.getSize("card_icon").height
width: parent.width width: parent.width
visible: manager.digitalFactoryProjectModel.count > 0 visible: manager.digitalFactoryProjectModel.count > 0
hasMoreProjectsToLoad: manager.hasMoreProjectsToLoad hasMoreProjectsToLoad: manager.hasMoreProjectsToLoad
@ -228,4 +222,4 @@ Item
x: Math.round((parent.width - width) / 2) x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2) y: Math.round((parent.height - height) / 2)
} }
} }

View file

@ -0,0 +1,203 @@
//Copyright (C) 2022 Ultimaker B.V.
//Cura is released under the terms of the LGPLv3 or higher.
import Qt.labs.qmlmodels 1.0
import QtQuick 2.15
import QtQuick.Controls 2.15
import UM 1.2 as UM
/*
* A re-sizeable table of data.
*
* This table combines a list of headers with a TableView to show certain roles in a table.
* The columns of the table can be resized.
* When the table becomes too big, you can scroll through the table. When a column becomes too small, the contents of
* the table are elided.
* The table gets Cura's themeing.
*/
Item
{
id: tableBase
required property var columnHeaders //The text to show in the headers of each column.
property alias model: tableView.model //A TableModel to display in this table. To use a ListModel for the rows, use "rows: listModel.items"
property int currentRow: -1 //The selected row index.
property var onDoubleClicked: function(row) {} //Something to execute when double clicked. Accepts one argument: The index of the row that was clicked on.
property bool allowSelection: true //Whether to allow the user to select items.
Row
{
id: headerBar
Repeater
{
id: headerRepeater
model: columnHeaders
Rectangle
{
//minimumWidth: Math.max(1, Math.round(tableBase.width / headerRepeater.count))
width: 300
height: UM.Theme.getSize("section").height
color: UM.Theme.getColor("secondary")
Label
{
id: contentText
anchors.left: parent.left
anchors.leftMargin: UM.Theme.getSize("narrow_margin").width
anchors.right: parent.right
anchors.rightMargin: UM.Theme.getSize("narrow_margin").width
text: modelData
font: UM.Theme.getFont("medium_bold")
color: UM.Theme.getColor("text")
elide: Text.ElideRight
}
Rectangle //Resize handle.
{
anchors
{
right: parent.right
top: parent.top
bottom: parent.bottom
}
width: UM.Theme.getSize("thick_lining").width
color: UM.Theme.getColor("thick_lining")
MouseArea
{
anchors.fill: parent
cursorShape: Qt.SizeHorCursor
drag
{
target: parent
axis: Drag.XAxis
}
onMouseXChanged:
{
if(drag.active)
{
let new_width = parent.parent.width + mouseX;
let sum_widths = mouseX;
for(let i = 0; i < headerBar.children.length; ++i)
{
sum_widths += headerBar.children[i].width;
}
if(sum_widths > tableBase.width)
{
new_width -= sum_widths - tableBase.width; //Limit the total width to not exceed the view.
}
let width_fraction = new_width / tableBase.width; //Scale with the same fraction along with the total width, if the table is resized.
parent.parent.width = Qt.binding(function() { return Math.max(10, Math.round(tableBase.width * width_fraction)) });
}
}
}
}
onWidthChanged:
{
tableView.forceLayout(); //Rescale table cells underneath as well.
}
}
}
}
TableView
{
id: tableView
anchors
{
top: headerBar.bottom
left: parent.left
right: parent.right
bottom: parent.bottom
}
flickableDirection: Flickable.AutoFlickIfNeeded
clip: true
ScrollBar.vertical: ScrollBar
{
// Vertical ScrollBar, styled similarly to the scrollBar in the settings panel
id: verticalScrollBar
visible: tableView.contentHeight > tableView.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; } }
}
}
columnWidthProvider: function(column)
{
return headerBar.children[column].width; //Cells get the same width as their column header.
}
delegate: Rectangle
{
implicitHeight: Math.max(1, cellContent.height)
color: UM.Theme.getColor((tableBase.currentRow == row) ? "primary" : ((row % 2 == 0) ? "main_background" : "viewport_background"))
Label
{
id: cellContent
width: parent.width
text: display
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("text")
}
TextMetrics
{
id: cellTextMetrics
text: cellContent.text
font: cellContent.font
elide: cellContent.elide
elideWidth: cellContent.width
}
UM.TooltipArea
{
anchors.fill: parent
acceptedButtons: Qt.LeftButton
text: (cellTextMetrics.elidedText == cellContent.text) ? "" : cellContent.text //Show full text in tooltip if it was elided.
onClicked:
{
if(tableBase.allowSelection)
{
tableBase.currentRow = row; //Select this row.
}
}
onDoubleClicked:
{
tableBase.onDoubleClicked(row);
}
}
}
Connections
{
target: model
function onRowCountChanged()
{
tableView.contentY = 0; //When the number of rows is reduced, make sure to scroll back to the start.
}
}
}
}

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

@ -14,6 +14,7 @@ from UM.Logger import Logger
from UM.Message import Message from UM.Message import Message
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from .BackwardsCompatibleMessage import getBackwardsCompatibleMessage
from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest
from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse
from .DFPrintJobUploadRequest import DFPrintJobUploadRequest from .DFPrintJobUploadRequest import DFPrintJobUploadRequest
@ -69,11 +70,11 @@ class DFFileExportAndUploadManager:
use_inactivity_timer = False use_inactivity_timer = False
) )
self._generic_success_message = Message( 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), 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", title = "Upload successful",
lifetime = 0, lifetime = 30,
message_type = Message.MessageType.POSITIVE message_type_str = "POSITIVE"
) )
self._generic_success_message.addAction( self._generic_success_message.addAction(
"open_df_project", "open_df_project",
@ -217,11 +218,11 @@ class DFFileExportAndUploadManager:
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck # 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_status"] = "failed"
self._file_upload_job_metadata[filename]["upload_progress"] = 100 self._file_upload_job_metadata[filename]["upload_progress"] = 100
self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message( self._file_upload_job_metadata[filename]["file_upload_failed_message"] = getBackwardsCompatibleMessage(
text = "Failed to export the file '{}'. The upload process is aborted.".format(filename), text = "Failed to export the file '{}'. The upload process is aborted.".format(filename),
title = "Export error", title = "Export error",
lifetime = 0, message_type_str = "ERROR",
message_type = Message.MessageType.ERROR lifetime = 30
) )
self._on_upload_error() self._on_upload_error()
self._onFileUploadFinished(filename) self._onFileUploadFinished(filename)
@ -240,11 +241,11 @@ class DFFileExportAndUploadManager:
self._file_upload_job_metadata[filename_3mf]["upload_progress"] = 100 self._file_upload_job_metadata[filename_3mf]["upload_progress"] = 100
human_readable_error = self.extractErrorTitle(reply_string) human_readable_error = self.extractErrorTitle(reply_string)
self._file_upload_job_metadata[filename_3mf]["file_upload_failed_message"] = Message( 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), text = "Failed to upload the file '{}' to '{}'. {}".format(filename_3mf, self._library_project_name, human_readable_error),
title = "File upload error", title = "File upload error",
lifetime = 0, message_type_str = "ERROR",
message_type = Message.MessageType.ERROR lifetime = 30
) )
self._on_upload_error() self._on_upload_error()
self._onFileUploadFinished(filename_3mf) self._onFileUploadFinished(filename_3mf)
@ -263,11 +264,11 @@ class DFFileExportAndUploadManager:
self._file_upload_job_metadata[filename_ufp]["upload_progress"] = 100 self._file_upload_job_metadata[filename_ufp]["upload_progress"] = 100
human_readable_error = self.extractErrorTitle(reply_string) human_readable_error = self.extractErrorTitle(reply_string)
self._file_upload_job_metadata[filename_ufp]["file_upload_failed_message"] = Message( self._file_upload_job_metadata[filename_ufp]["file_upload_failed_message"] = getBackwardsCompatibleMessage(
title = "File upload error", title = "File upload error",
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_ufp, self._library_project_name, human_readable_error), text = "Failed to upload the file '{}' to '{}'. {}".format(filename_ufp, self._library_project_name, human_readable_error),
lifetime = 0, message_type_str = "ERROR",
message_type = Message.MessageType.ERROR lifetime = 30
) )
self._on_upload_error() self._on_upload_error()
self._onFileUploadFinished(filename_ufp) self._onFileUploadFinished(filename_ufp)
@ -300,11 +301,11 @@ class DFFileExportAndUploadManager:
self._file_upload_job_metadata[filename]["upload_status"] = "failed" self._file_upload_job_metadata[filename]["upload_status"] = "failed"
self._file_upload_job_metadata[filename]["upload_progress"] = 100 self._file_upload_job_metadata[filename]["upload_progress"] = 100
human_readable_error = self.extractErrorTitle(reply_string) human_readable_error = self.extractErrorTitle(reply_string)
self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message( self._file_upload_job_metadata[filename]["file_upload_failed_message"] = getBackwardsCompatibleMessage(
title = "File upload error", title = "File upload error",
text = "Failed to upload the file '{}' to '{}'. {}".format(self._file_name, self._library_project_name, human_readable_error), text = "Failed to upload the file '{}' to '{}'. {}".format(self._file_name, self._library_project_name, human_readable_error),
lifetime = 0, message_type_str = "ERROR",
message_type = Message.MessageType.ERROR lifetime = 30
) )
self._on_upload_error() self._on_upload_error()
@ -319,7 +320,7 @@ class DFFileExportAndUploadManager:
def _onMessageActionTriggered(self, message, action): def _onMessageActionTriggered(self, message, action):
if action == "open_df_project": if action == "open_df_project":
project_url = "{}/app/library/project/{}?wait_for_new_files=true".format(CuraApplication.getInstance().ultimakerDigitalFactoryUrl, self._library_project_id) 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)) QDesktopServices.openUrl(QUrl(project_url))
message.hide() message.hide()
@ -337,17 +338,17 @@ class DFFileExportAndUploadManager:
"upload_progress" : -1, "upload_progress" : -1,
"upload_status" : "", "upload_status" : "",
"file_upload_response": None, "file_upload_response": None,
"file_upload_success_message": Message( "file_upload_success_message": getBackwardsCompatibleMessage(
text = "'{}' was uploaded to '{}'.".format(filename_3mf, self._library_project_name), text = "'{}' was uploaded to '{}'.".format(filename_3mf, self._library_project_name),
title = "Upload successful", title = "Upload successful",
lifetime = 0, message_type_str = "POSITIVE",
message_type = Message.MessageType.POSITIVE lifetime = 30
), ),
"file_upload_failed_message": Message( "file_upload_failed_message": getBackwardsCompatibleMessage(
text = "Failed to upload the file '{}' to '{}'.".format(filename_3mf, self._library_project_name), text = "Failed to upload the file '{}' to '{}'.".format(filename_3mf, self._library_project_name),
title = "File upload error", title = "File upload error",
lifetime = 0, message_type_str = "ERROR",
message_type = Message.MessageType.ERROR lifetime = 30
) )
} }
job_3mf = ExportFileJob(self._file_handlers["3mf"], self._nodes, self._file_name, "3mf") job_3mf = ExportFileJob(self._file_handlers["3mf"], self._nodes, self._file_name, "3mf")
@ -361,17 +362,17 @@ class DFFileExportAndUploadManager:
"upload_progress" : -1, "upload_progress" : -1,
"upload_status" : "", "upload_status" : "",
"file_upload_response": None, "file_upload_response": None,
"file_upload_success_message": Message( "file_upload_success_message": getBackwardsCompatibleMessage(
text = "'{}' was uploaded to '{}'.".format(filename_ufp, self._library_project_name), text = "'{}' was uploaded to '{}'.".format(filename_ufp, self._library_project_name),
title = "Upload successful", title = "Upload successful",
lifetime = 0, message_type_str = "POSITIVE",
message_type = Message.MessageType.POSITIVE lifetime = 30,
), ),
"file_upload_failed_message": Message( "file_upload_failed_message": getBackwardsCompatibleMessage(
text = "Failed to upload the file '{}' to '{}'.".format(filename_ufp, self._library_project_name), text = "Failed to upload the file '{}' to '{}'.".format(filename_ufp, self._library_project_name),
title = "File upload error", title = "File upload error",
lifetime = 0, message_type_str = "ERROR",
message_type = Message.MessageType.ERROR lifetime = 30
) )
} }
job_ufp = ExportFileJob(self._file_handlers["ufp"], self._nodes, self._file_name, "ufp") job_ufp = ExportFileJob(self._file_handlers["ufp"], self._nodes, self._file_name, "ufp")

View file

@ -67,10 +67,12 @@ class DigitalFactoryApiClient:
def callbackWrap(response: Optional[Any] = None, *args, **kwargs) -> None: def callbackWrap(response: Optional[Any] = None, *args, **kwargs) -> None:
if (response is not None and isinstance(response, DigitalFactoryFeatureBudgetResponse) and if (response is not None and isinstance(response, DigitalFactoryFeatureBudgetResponse) and
response.library_max_private_projects is not None): response.library_max_private_projects is not None):
callback( # A user has DF access when library_max_private_projects is either -1 (unlimited) or bigger then 0
response.library_max_private_projects == -1 or # Note: -1 is unlimited has_access = response.library_max_private_projects == -1 or response.library_max_private_projects > 0
response.library_max_private_projects > 0) callback(has_access)
self._library_max_private_projects = response.library_max_private_projects self._library_max_private_projects = response.library_max_private_projects
# update the account with the additional user rights
self._account.updateAdditionalRight(df_access = has_access)
else: else:
Logger.warning(f"Digital Factory: Response is not a feature budget, likely an error: {str(response)}") Logger.warning(f"Digital Factory: Response is not a feature budget, likely an error: {str(response)}")
callback(False) callback(False)

View file

@ -23,6 +23,7 @@ from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from cura.API import Account from cura.API import Account
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
from .BackwardsCompatibleMessage import getBackwardsCompatibleMessage
from .DFFileExportAndUploadManager import DFFileExportAndUploadManager from .DFFileExportAndUploadManager import DFFileExportAndUploadManager
from .DigitalFactoryApiClient import DigitalFactoryApiClient from .DigitalFactoryApiClient import DigitalFactoryApiClient
from .DigitalFactoryFileModel import DigitalFactoryFileModel from .DigitalFactoryFileModel import DigitalFactoryFileModel
@ -260,7 +261,10 @@ class DigitalFactoryController(QObject):
""" """
Error function, called whenever the retrieval of the files in a library project fails. Error function, called whenever the retrieval of the files in a library project fails.
""" """
Logger.log("w", "Failed to retrieve the list of files in project '{}' from the Digital Library".format(self._project_model._projects[self._selected_project_idx])) try:
Logger.warning(f"Failed to retrieve the list of files in project '{self._project_model._projects[self._selected_project_idx]}' from the Digital Library")
except IndexError:
Logger.warning(f"Failed to retrieve the list of files in a project from the Digital Library. And failed to get the project too.")
self.setRetrievingFilesStatus(RetrievalStatus.Failed) self.setRetrievingFilesStatus(RetrievalStatus.Failed)
@pyqtSlot() @pyqtSlot()
@ -527,11 +531,11 @@ class DigitalFactoryController(QObject):
except IOError as ex: except IOError as ex:
Logger.logException("e", "Can't write Digital Library file {0}/{1} download to temp-directory {2}.", Logger.logException("e", "Can't write Digital Library file {0}/{1} download to temp-directory {2}.",
ex, project_name, file_name, temp_dir) ex, project_name, file_name, temp_dir)
Message( getBackwardsCompatibleMessage(
text = "Failed to write to temporary file for '{}'.".format(file_name), text = "Failed to write to temporary file for '{}'.".format(file_name),
title = "File-system error", title = "File-system error",
lifetime = 10, message_type_str="ERROR",
message_type=Message.MessageType.ERROR lifetime = 10
).show() ).show()
return return
@ -542,11 +546,11 @@ class DigitalFactoryController(QObject):
f = file_name) -> None: f = file_name) -> None:
progress_message.hide() progress_message.hide()
Logger.error("An error {0} {1} occurred while downloading {2}/{3}".format(str(error), str(reply), p, f)) Logger.error("An error {0} {1} occurred while downloading {2}/{3}".format(str(error), str(reply), p, f))
Message( getBackwardsCompatibleMessage(
text = "Failed Digital Library download for '{}'.".format(f), text = "Failed Digital Library download for '{}'.".format(f),
title = "Network error {}".format(error), title = "Network error {}".format(error),
lifetime = 10, message_type_str="ERROR",
message_type=Message.MessageType.ERROR lifetime = 10
).show() ).show()
download_manager = HttpRequestManager.getInstance() download_manager = HttpRequestManager.getInstance()
@ -591,17 +595,19 @@ class DigitalFactoryController(QObject):
if filename == "": if filename == "":
Logger.log("w", "The file name cannot be empty.") Logger.log("w", "The file name cannot be empty.")
Message(text = "Cannot upload file with an empty name to the Digital Library", getBackwardsCompatibleMessage(
text = "Cannot upload file with an empty name to the Digital Library",
title = "Empty file name provided", title = "Empty file name provided",
lifetime = 0, message_type_str = "ERROR",
message_type = Message.MessageType.ERROR).show() lifetime = 0
).show()
return return
self._saveFileToSelectedProjectHelper(filename, formats) self._saveFileToSelectedProjectHelper(filename, formats)
def _saveFileToSelectedProjectHelper(self, filename: str, formats: List[str]) -> None: def _saveFileToSelectedProjectHelper(self, filename: str, formats: List[str]) -> None:
# Indicate we have started sending a job. # Indicate we have started sending a job (and propagate any user file name changes back to the open project)
self.uploadStarted.emit() self.uploadStarted.emit(filename if "3mf" in formats else None)
library_project_id = self._project_model.items[self._selected_project_idx]["libraryProjectId"] library_project_id = self._project_model.items[self._selected_project_idx]["libraryProjectId"]
library_project_name = self._project_model.items[self._selected_project_idx]["displayName"] library_project_name = self._project_model.items[self._selected_project_idx]["displayName"]

View file

@ -8,6 +8,8 @@ from UM.Logger import Logger
from UM.OutputDevice import OutputDeviceError from UM.OutputDevice import OutputDeviceError
from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Version import Version
from cura import ApplicationMetadata
from cura.API import Account from cura.API import Account
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from .DigitalFactoryController import DigitalFactoryController from .DigitalFactoryController import DigitalFactoryController
@ -105,8 +107,11 @@ class DigitalFactoryOutputDevice(ProjectOutputDevice):
self.enabled = logged_in and self._controller.userAccountHasLibraryAccess() self.enabled = logged_in and self._controller.userAccountHasLibraryAccess()
self.enabledChanged.emit() self.enabledChanged.emit()
def _onWriteStarted(self) -> None: def _onWriteStarted(self, new_name: Optional[str] = None) -> None:
self._writing = True self._writing = True
if new_name and Version(ApplicationMetadata.CuraSDKVersion) >= Version("7.8.0"):
# setLastOutputName is only supported in sdk version 7.8.0 and up
self.setLastOutputName(new_name) # On saving, the user can change the name, this should propagate.
self.writeStarted.emit(self) self.writeStarted.emit(self)
def _onWriteFinished(self) -> None: def _onWriteFinished(self) -> None:

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