diff options
Diffstat (limited to 'cura')
117 files changed, 4905 insertions, 3034 deletions
diff --git a/cura/API/Account.py b/cura/API/Account.py index 0e3af0e6c1..15bccb71e1 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -1,15 +1,16 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Optional, Dict, TYPE_CHECKING +from datetime import datetime +from typing import Optional, Dict, TYPE_CHECKING, Callable -from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty +from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS -from UM.i18n import i18nCatalog +from UM.Logger import Logger from UM.Message import Message -from cura import UltimakerCloudAuthentication - +from UM.i18n import i18nCatalog from cura.OAuth2.AuthorizationService import AuthorizationService from cura.OAuth2.Models import OAuth2Settings +from cura.UltimakerCloud import UltimakerCloudConstants if TYPE_CHECKING: from cura.CuraApplication import CuraApplication @@ -17,29 +18,61 @@ if TYPE_CHECKING: i18n_catalog = i18nCatalog("cura") -## The account API provides a version-proof bridge to use Ultimaker Accounts -# -# Usage: -# ``from cura.API import CuraAPI -# api = CuraAPI() -# api.account.login() -# api.account.logout() -# api.account.userProfile # Who is logged in`` -# +class SyncState: + """QML: Cura.AccountSyncState""" + SYNCING = 0 + SUCCESS = 1 + ERROR = 2 + IDLE = 3 + class Account(QObject): - # Signal emitted when user logged in or out. + """The account API provides a version-proof bridge to use Ultimaker Accounts + + Usage: + + .. code-block:: python + + from cura.API import CuraAPI + api = CuraAPI() + api.account.login() + api.account.logout() + api.account.userProfile # Who is logged in + """ + + # The interval in which sync services are automatically triggered + SYNC_INTERVAL = 30.0 # seconds + Q_ENUMS(SyncState) + loginStateChanged = pyqtSignal(bool) + """Signal emitted when user logged in or out""" + accessTokenChanged = pyqtSignal() + syncRequested = pyqtSignal() + """Sync services may connect to this signal to receive sync triggers. + Services should be resilient to receiving a signal while they are still syncing, + either by ignoring subsequent signals or restarting a sync. + See setSyncState() for providing user feedback on the state of your service. + """ + lastSyncDateTimeChanged = pyqtSignal() + syncStateChanged = pyqtSignal(int) # because SyncState is an int Enum + manualSyncEnabledChanged = pyqtSignal(bool) + updatePackagesEnabledChanged = pyqtSignal(bool) def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) self._application = application + self._new_cloud_printers_detected = False self._error_message = None # type: Optional[Message] self._logged_in = False + self._sync_state = SyncState.IDLE + self._manual_sync_enabled = False + self._update_packages_enabled = False + self._update_packages_action = None # type: Optional[Callable] + self._last_sync_str = "-" self._callback_port = 32118 - self._oauth_root = UltimakerCloudAuthentication.CuraCloudAccountAPIRoot + self._oauth_root = UltimakerCloudConstants.CuraCloudAccountAPIRoot self._oauth_settings = OAuth2Settings( OAUTH_SERVER_URL= self._oauth_root, @@ -56,6 +89,16 @@ class Account(QObject): self._authorization_service = AuthorizationService(self._oauth_settings) + # Create a timer for automatic account sync + self._update_timer = QTimer() + self._update_timer.setInterval(int(self.SYNC_INTERVAL * 1000)) + # The timer is restarted explicitly after an update was processed. This prevents 2 concurrent updates + self._update_timer.setSingleShot(True) + self._update_timer.timeout.connect(self.sync) + + self._sync_services = {} # type: Dict[str, int] + """contains entries "service_name" : SyncState""" + def initialize(self) -> None: self._authorization_service.initialize(self._application.getPreferences()) self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged) @@ -63,12 +106,65 @@ class Account(QObject): self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged) self._authorization_service.loadAuthDataFromPreferences() + + @pyqtProperty(int, notify=syncStateChanged) + def syncState(self): + return self._sync_state + + def setSyncState(self, service_name: str, state: int) -> None: + """ Can be used to register sync services and update account sync states + + Contract: A sync service is expected exit syncing state in all cases, within reasonable time + + Example: `setSyncState("PluginSyncService", SyncState.SYNCING)` + :param service_name: A unique name for your service, such as `plugins` or `backups` + :param state: One of SyncState + """ + prev_state = self._sync_state + + self._sync_services[service_name] = state + + if any(val == SyncState.SYNCING for val in self._sync_services.values()): + self._sync_state = SyncState.SYNCING + self._setManualSyncEnabled(False) + elif any(val == SyncState.ERROR for val in self._sync_services.values()): + self._sync_state = SyncState.ERROR + self._setManualSyncEnabled(True) + else: + self._sync_state = SyncState.SUCCESS + self._setManualSyncEnabled(False) + + if self._sync_state != prev_state: + self.syncStateChanged.emit(self._sync_state) + + if self._sync_state == SyncState.SUCCESS: + self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M") + self.lastSyncDateTimeChanged.emit() + + if self._sync_state != SyncState.SYNCING: + # schedule new auto update after syncing completed (for whatever reason) + if not self._update_timer.isActive(): + self._update_timer.start() + + def setUpdatePackagesAction(self, action: Callable) -> None: + """ Set the callback which will be invoked when the user clicks the update packages button + + Should be invoked after your service sets the sync state to SYNCING and before setting the + sync state to SUCCESS. + + Action will be reset to None when the next sync starts + """ + self._update_packages_action = action + self._update_packages_enabled = True + self.updatePackagesEnabledChanged.emit(self._update_packages_enabled) + def _onAccessTokenChanged(self): self.accessTokenChanged.emit() - ## Returns a boolean indicating whether the given authentication is applied against staging or not. @property def is_staging(self) -> bool: + """Indication whether the given authentication is applied against staging or not.""" + return "staging" in self._oauth_root @pyqtProperty(bool, notify=loginStateChanged) @@ -83,18 +179,60 @@ class Account(QObject): self._error_message.show() self._logged_in = False self.loginStateChanged.emit(False) + if self._update_timer.isActive(): + self._update_timer.stop() return if self._logged_in != logged_in: self._logged_in = logged_in self.loginStateChanged.emit(logged_in) + if logged_in: + self._setManualSyncEnabled(False) + self._sync() + else: + if self._update_timer.isActive(): + self._update_timer.stop() + + def _sync(self) -> None: + """Signals all sync services to start syncing + + This can be considered a forced sync: even when a + sync is currently running, a sync will be requested. + """ + + self._update_packages_action = None + self._update_packages_enabled = False + self.updatePackagesEnabledChanged.emit(self._update_packages_enabled) + if self._update_timer.isActive(): + self._update_timer.stop() + elif self._sync_state == SyncState.SYNCING: + Logger.warning("Starting a new sync while previous sync was not completed\n{}", str(self._sync_services)) + + self.syncRequested.emit() + + def _setManualSyncEnabled(self, enabled: bool) -> None: + if self._manual_sync_enabled != enabled: + self._manual_sync_enabled = enabled + self.manualSyncEnabledChanged.emit(enabled) @pyqtSlot() - def login(self) -> None: + @pyqtSlot(bool) + def login(self, force_logout_before_login: bool = False) -> None: + """ + Initializes the login process. If the user is logged in already and force_logout_before_login is true, Cura will + logout from the account before initiating the authorization flow. If the user is logged in and + force_logout_before_login is false, the function will return, as there is nothing to do. + + :param force_logout_before_login: Optional boolean parameter + :return: None + """ if self._logged_in: - # Nothing to do, user already logged in. - return - self._authorization_service.startAuthorizationFlow() + if force_logout_before_login: + self.logout() + else: + # Nothing to do, user already logged in. + return + self._authorization_service.startAuthorizationFlow(force_logout_before_login) @pyqtProperty(str, notify=loginStateChanged) def userName(self): @@ -114,15 +252,44 @@ class Account(QObject): def accessToken(self) -> Optional[str]: return self._authorization_service.getAccessToken() - # Get the profile of the logged in user - # @returns None if no user is logged in, a dict containing user_id, username and profile_image_url @pyqtProperty("QVariantMap", notify = loginStateChanged) 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 """ + user_profile = self._authorization_service.getUserProfile() if not user_profile: return None return user_profile.__dict__ + @pyqtProperty(str, notify=lastSyncDateTimeChanged) + def lastSyncDateTime(self) -> str: + return self._last_sync_str + + @pyqtProperty(bool, notify=manualSyncEnabledChanged) + def manualSyncEnabled(self) -> bool: + return self._manual_sync_enabled + + @pyqtProperty(bool, notify=updatePackagesEnabledChanged) + def updatePackagesEnabled(self) -> bool: + return self._update_packages_enabled + + @pyqtSlot() + @pyqtSlot(bool) + def sync(self, user_initiated: bool = False) -> None: + if user_initiated: + self._setManualSyncEnabled(False) + + self._sync() + + @pyqtSlot() + def onUpdatePackagesClicked(self) -> None: + if self._update_packages_action is not None: + self._update_packages_action() + + @pyqtSlot() + def popupOpened(self) -> None: + self._setManualSyncEnabled(True) + @pyqtSlot() def logout(self) -> None: if not self._logged_in: diff --git a/cura/API/Backups.py b/cura/API/Backups.py index ef74e74be0..1940d38a36 100644 --- a/cura/API/Backups.py +++ b/cura/API/Backups.py @@ -8,28 +8,37 @@ if TYPE_CHECKING: from cura.CuraApplication import CuraApplication -## The back-ups API provides a version-proof bridge between Cura's -# BackupManager and plug-ins that hook into it. -# -# Usage: -# ``from cura.API import CuraAPI -# api = CuraAPI() -# api.backups.createBackup() -# api.backups.restoreBackup(my_zip_file, {"cura_release": "3.1"})`` class Backups: + """The back-ups API provides a version-proof bridge between Cura's + + BackupManager and plug-ins that hook into it. + + Usage: + + .. code-block:: python + + from cura.API import CuraAPI + api = CuraAPI() + api.backups.createBackup() + api.backups.restoreBackup(my_zip_file, {"cura_release": "3.1"}) + """ def __init__(self, application: "CuraApplication") -> None: self.manager = BackupsManager(application) - ## Create a new back-up using the BackupsManager. - # \return Tuple containing a ZIP file with the back-up data and a dict - # with metadata about the back-up. def createBackup(self) -> Tuple[Optional[bytes], Optional[Dict[str, Any]]]: + """Create a new back-up using the BackupsManager. + + :return: Tuple containing a ZIP file with the back-up data and a dict with metadata about the back-up. + """ + return self.manager.createBackup() - ## Restore a back-up using the BackupsManager. - # \param zip_file A ZIP file containing the actual back-up data. - # \param meta_data Some metadata needed for restoring a back-up, like the - # Cura version number. def restoreBackup(self, zip_file: bytes, meta_data: Dict[str, Any]) -> None: + """Restore a back-up using the BackupsManager. + + :param zip_file: A ZIP file containing the actual back-up data. + :param meta_data: Some metadata needed for restoring a back-up, like the Cura version number. + """ + return self.manager.restoreBackup(zip_file, meta_data) diff --git a/cura/API/ConnectionStatus.py b/cura/API/ConnectionStatus.py new file mode 100644 index 0000000000..36f804e3cf --- /dev/null +++ b/cura/API/ConnectionStatus.py @@ -0,0 +1,41 @@ +from typing import Optional + +from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty + +from UM.TaskManagement.HttpRequestManager import HttpRequestManager + + +class ConnectionStatus(QObject): + """Provides an estimation of whether internet is reachable + + Estimation is updated with every request through HttpRequestManager. + Acts as a proxy to HttpRequestManager.internetReachableChanged without + exposing the HttpRequestManager in its entirety. + """ + + __instance = None # type: Optional[ConnectionStatus] + + internetReachableChanged = pyqtSignal() + + @classmethod + def getInstance(cls, *args, **kwargs) -> "ConnectionStatus": + if cls.__instance is None: + cls.__instance = cls(*args, **kwargs) + return cls.__instance + + def __init__(self, parent: Optional["QObject"] = None) -> None: + super().__init__(parent) + + manager = HttpRequestManager.getInstance() + self._is_internet_reachable = manager.isInternetReachable # type: bool + manager.internetReachableChanged.connect(self._onInternetReachableChanged) + + @pyqtProperty(bool, notify = internetReachableChanged) + def isInternetReachable(self) -> bool: + return self._is_internet_reachable + + def _onInternetReachableChanged(self, reachable: bool): + if reachable != self._is_internet_reachable: + self._is_internet_reachable = reachable + self.internetReachableChanged.emit() + diff --git a/cura/API/Interface/Settings.py b/cura/API/Interface/Settings.py index 371c40c14c..706a6d8c74 100644 --- a/cura/API/Interface/Settings.py +++ b/cura/API/Interface/Settings.py @@ -7,32 +7,43 @@ if TYPE_CHECKING: from cura.CuraApplication import CuraApplication -## The Interface.Settings API provides a version-proof bridge between Cura's -# (currently) sidebar UI and plug-ins that hook into it. -# -# Usage: -# ``from cura.API import CuraAPI -# api = CuraAPI() -# api.interface.settings.getContextMenuItems() -# data = { -# "name": "My Plugin Action", -# "iconName": "my-plugin-icon", -# "actions": my_menu_actions, -# "menu_item": MyPluginAction(self) -# } -# api.interface.settings.addContextMenuItem(data)`` - class Settings: + """The Interface.Settings API provides a version-proof bridge + between Cura's + + (currently) sidebar UI and plug-ins that hook into it. + + Usage: + + .. code-block:: python + + from cura.API import CuraAPI + api = CuraAPI() + api.interface.settings.getContextMenuItems() + data = { + "name": "My Plugin Action", + "iconName": "my-plugin-icon", + "actions": my_menu_actions, + "menu_item": MyPluginAction(self) + } + api.interface.settings.addContextMenuItem(data) + """ def __init__(self, application: "CuraApplication") -> None: self.application = application - ## Add items to the sidebar context menu. - # \param menu_item dict containing the menu item to add. def addContextMenuItem(self, menu_item: dict) -> None: + """Add items to the sidebar context menu. + + :param menu_item: dict containing the menu item to add. + """ + self.application.addSidebarCustomMenuItem(menu_item) - ## Get all custom items currently added to the sidebar context menu. - # \return List containing all custom context menu items. def getContextMenuItems(self) -> list: + """Get all custom items currently added to the sidebar context menu. + + :return: List containing all custom context menu items. + """ + return self.application.getSidebarCustomMenuItems() diff --git a/cura/API/Interface/__init__.py b/cura/API/Interface/__init__.py index cec174bf0a..61510d6262 100644 --- a/cura/API/Interface/__init__.py +++ b/cura/API/Interface/__init__.py @@ -9,18 +9,22 @@ if TYPE_CHECKING: from cura.CuraApplication import CuraApplication -## The Interface class serves as a common root for the specific API -# methods for each interface element. -# -# Usage: -# ``from cura.API import CuraAPI -# api = CuraAPI() -# api.interface.settings.addContextMenuItem() -# api.interface.viewport.addOverlay() # Not implemented, just a hypothetical -# api.interface.toolbar.getToolButtonCount() # Not implemented, just a hypothetical -# # etc.`` - class Interface: + """The Interface class serves as a common root for the specific API + + methods for each interface element. + + Usage: + + .. code-block:: python + + from cura.API import CuraAPI + api = CuraAPI() + api.interface.settings.addContextMenuItem() + api.interface.viewport.addOverlay() # Not implemented, just a hypothetical + api.interface.toolbar.getToolButtonCount() # Not implemented, just a hypothetical + # etc + """ def __init__(self, application: "CuraApplication") -> None: # API methods specific to the settings portion of the UI diff --git a/cura/API/__init__.py b/cura/API/__init__.py index 26c9a4c829..447be98e4b 100644 --- a/cura/API/__init__.py +++ b/cura/API/__init__.py @@ -5,6 +5,7 @@ from typing import Optional, TYPE_CHECKING from PyQt5.QtCore import QObject, pyqtProperty from cura.API.Backups import Backups +from cura.API.ConnectionStatus import ConnectionStatus from cura.API.Interface import Interface from cura.API.Account import Account @@ -12,13 +13,14 @@ if TYPE_CHECKING: from cura.CuraApplication import CuraApplication -## The official Cura API that plug-ins can use to interact with Cura. -# -# Python does not technically prevent talking to other classes as well, but -# this API provides a version-safe interface with proper deprecation warnings -# etc. Usage of any other methods than the ones provided in this API can cause -# plug-ins to be unstable. class CuraAPI(QObject): + """The official Cura API that plug-ins can use to interact with Cura. + + Python does not technically prevent talking to other classes as well, but this API provides a version-safe + interface with proper deprecation warnings etc. Usage of any other methods than the ones provided in this API can + cause plug-ins to be unstable. + """ + # For now we use the same API version to be consistent. __instance = None # type: "CuraAPI" @@ -39,12 +41,12 @@ class CuraAPI(QObject): def __init__(self, application: Optional["CuraApplication"] = None) -> None: super().__init__(parent = CuraAPI._application) - # Accounts API self._account = Account(self._application) - # Backups API self._backups = Backups(self._application) + self._connectionStatus = ConnectionStatus() + # Interface API self._interface = Interface(self._application) @@ -53,12 +55,22 @@ class CuraAPI(QObject): @pyqtProperty(QObject, constant = True) def account(self) -> "Account": + """Accounts API""" + return self._account + @pyqtProperty(QObject, constant = True) + def connectionStatus(self) -> "ConnectionStatus": + return self._connectionStatus + @property def backups(self) -> "Backups": + """Backups API""" + return self._backups @property def interface(self) -> "Interface": + """Interface API""" + return self._interface diff --git a/cura/ApplicationMetadata.py b/cura/ApplicationMetadata.py index c16051d187..2e15d60a93 100644 --- a/cura/ApplicationMetadata.py +++ b/cura/ApplicationMetadata.py @@ -13,7 +13,7 @@ DEFAULT_CURA_DEBUG_MODE = False # Each release has a fixed SDK version coupled with it. It doesn't make sense to make it configurable because, for # example Cura 3.2 with SDK version 6.1 will not work. So the SDK version is hard-coded here and left out of the # CuraVersion.py.in template. -CuraSDKVersion = "7.1.0" +CuraSDKVersion = "7.4.0" try: from cura.CuraVersion import CuraAppName # type: ignore diff --git a/cura/Arranging/Arrange.py b/cura/Arranging/Arrange.py index c0aca9a893..e4a64afd3f 100644 --- a/cura/Arranging/Arrange.py +++ b/cura/Arranging/Arrange.py @@ -1,7 +1,8 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import List, Optional +from typing import Optional +from UM.Decorators import deprecated from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Logger import Logger from UM.Math.Polygon import Polygon @@ -16,21 +17,24 @@ from collections import namedtuple import numpy import copy - -## Return object for bestSpot LocationSuggestion = namedtuple("LocationSuggestion", ["x", "y", "penalty_points", "priority"]) +"""Return object for bestSpot""" -## The Arrange classed is used together with ShapeArray. Use it to find -# good locations for objects that you try to put on a build place. -# Different priority schemes can be defined so it alters the behavior while using -# the same logic. -# -# Note: Make sure the scale is the same between ShapeArray objects and the Arrange instance. class Arrange: + """ + The Arrange classed is used together with :py:class:`cura.Arranging.ShapeArray.ShapeArray`. Use it to find good locations for objects that you try to put + on a build place. Different priority schemes can be defined so it alters the behavior while using the same logic. + + .. note:: + + Make sure the scale is the same between :py:class:`cura.Arranging.ShapeArray.ShapeArray` objects and the :py:class:`cura.Arranging.Arrange.Arrange` instance. + """ + build_volume = None # type: Optional[BuildVolume] - def __init__(self, x, y, offset_x, offset_y, scale= 0.5): + @deprecated("Use the functions in Nest2dArrange instead", "4.8") + def __init__(self, x, y, offset_x, offset_y, scale = 0.5): self._scale = scale # convert input coordinates to arrange coordinates world_x, world_y = int(x * self._scale), int(y * self._scale) self._shape = (world_y, world_x) @@ -42,14 +46,22 @@ class Arrange: self._last_priority = 0 self._is_empty = True - ## Helper to create an Arranger instance - # - # Either fill in scene_root and create will find all sliceable nodes by itself, - # or use fixed_nodes to provide the nodes yourself. - # \param scene_root Root for finding all scene nodes - # \param fixed_nodes Scene nodes to be placed @classmethod - def create(cls, scene_root = None, fixed_nodes = None, scale = 0.5, x = 350, y = 250, min_offset = 8): + @deprecated("Use the functions in Nest2dArrange instead", "4.8") + def create(cls, scene_root = None, fixed_nodes = None, scale = 0.5, x = 350, y = 250, min_offset = 8) -> "Arrange": + """Helper to create an :py:class:`cura.Arranging.Arrange.Arrange` instance + + Either fill in scene_root and create will find all sliceable nodes by itself, or use fixed_nodes to provide the + nodes yourself. + + :param scene_root: Root for finding all scene nodes default = None + :param fixed_nodes: Scene nodes to be placed default = None + :param scale: default = 0.5 + :param x: default = 350 + :param y: default = 250 + :param min_offset: default = 8 + """ + arranger = Arrange(x, y, x // 2, y // 2, scale = scale) arranger.centerFirst() @@ -71,8 +83,11 @@ class Arrange: # After scaling (like up to 0.1 mm) the node might not have points if not points.size: continue - - shape_arr = ShapeArray.fromPolygon(points, scale = scale) + try: + shape_arr = ShapeArray.fromPolygon(points, scale = scale) + except ValueError: + Logger.logException("w", "Unable to create polygon") + continue arranger.place(0, 0, shape_arr) # If a build volume was set, add the disallowed areas @@ -84,16 +99,22 @@ class Arrange: arranger.place(0, 0, shape_arr, update_empty = False) return arranger - ## This resets the optimization for finding location based on size def resetLastPriority(self): + """This resets the optimization for finding location based on size""" + self._last_priority = 0 - ## Find placement for a node (using offset shape) and place it (using hull shape) - # return the nodes that should be placed - # \param node - # \param offset_shape_arr ShapeArray with offset, for placing the shape - # \param hull_shape_arr ShapeArray without offset, used to find location - def findNodePlacement(self, node: SceneNode, offset_shape_arr: ShapeArray, hull_shape_arr: ShapeArray, step = 1): + @deprecated("Use the functions in Nest2dArrange instead", "4.8") + def findNodePlacement(self, node: SceneNode, offset_shape_arr: ShapeArray, hull_shape_arr: ShapeArray, step = 1) -> bool: + """Find placement for a node (using offset shape) and place it (using hull shape) + + :param node: The node to be placed + :param offset_shape_arr: shape array with offset, for placing the shape + :param hull_shape_arr: shape array without offset, used to find location + :param step: default = 1 + :return: the nodes that should be placed + """ + best_spot = self.bestSpot( hull_shape_arr, start_prio = self._last_priority, step = step) x, y = best_spot.x, best_spot.y @@ -119,29 +140,32 @@ class Arrange: node.setPosition(Vector(200, center_y, 100)) return found_spot - ## Fill priority, center is best. Lower value is better - # This is a strategy for the arranger. def centerFirst(self): + """Fill priority, center is best. Lower value is better. """ + # Square distance: creates a more round shape self._priority = numpy.fromfunction( lambda j, i: (self._offset_x - i) ** 2 + (self._offset_y - j) ** 2, self._shape, dtype=numpy.int32) self._priority_unique_values = numpy.unique(self._priority) self._priority_unique_values.sort() - ## Fill priority, back is best. Lower value is better - # This is a strategy for the arranger. def backFirst(self): + """Fill priority, back is best. Lower value is better """ + self._priority = numpy.fromfunction( lambda j, i: 10 * j + abs(self._offset_x - i), self._shape, dtype=numpy.int32) self._priority_unique_values = numpy.unique(self._priority) self._priority_unique_values.sort() - ## Return the amount of "penalty points" for polygon, which is the sum of priority - # None if occupied - # \param x x-coordinate to check shape - # \param y y-coordinate - # \param shape_arr the ShapeArray object to place - def checkShape(self, x, y, shape_arr): + def checkShape(self, x, y, shape_arr) -> Optional[numpy.ndarray]: + """Return the amount of "penalty points" for polygon, which is the sum of priority + + :param x: x-coordinate to check shape + :param y: y-coordinate to check shape + :param shape_arr: the shape array object to place + :return: None if occupied + """ + x = int(self._scale * x) y = int(self._scale * y) offset_x = x + self._offset_x + shape_arr.offset_x @@ -165,17 +189,24 @@ class Arrange: offset_x:offset_x + shape_arr.arr.shape[1]] return numpy.sum(prio_slice[numpy.where(shape_arr.arr == 1)]) - ## Find "best" spot for ShapeArray - # Return namedtuple with properties x, y, penalty_points, priority. - # \param shape_arr ShapeArray - # \param start_prio Start with this priority value (and skip the ones before) - # \param step Slicing value, higher = more skips = faster but less accurate - def bestSpot(self, shape_arr, start_prio = 0, step = 1): + def bestSpot(self, shape_arr, start_prio = 0, step = 1) -> LocationSuggestion: + """Find "best" spot for ShapeArray + + :param shape_arr: shape array + :param start_prio: Start with this priority value (and skip the ones before) + :param step: Slicing value, higher = more skips = faster but less accurate + :return: namedtuple with properties x, y, penalty_points, priority. + """ + start_idx_list = numpy.where(self._priority_unique_values == start_prio) if start_idx_list: - start_idx = start_idx_list[0][0] + try: + start_idx = start_idx_list[0][0] + except IndexError: + start_idx = 0 else: start_idx = 0 + priority = 0 for priority in self._priority_unique_values[start_idx::step]: tryout_idx = numpy.where(self._priority == priority) for idx in range(len(tryout_idx[0])): @@ -189,13 +220,17 @@ class Arrange: return LocationSuggestion(x = projected_x, y = projected_y, penalty_points = penalty_points, priority = priority) return LocationSuggestion(x = None, y = None, penalty_points = None, priority = priority) # No suitable location found :-( - ## Place the object. - # Marks the locations in self._occupied and self._priority - # \param x x-coordinate - # \param y y-coordinate - # \param shape_arr ShapeArray object - # \param update_empty updates the _is_empty, used when adding disallowed areas def place(self, x, y, shape_arr, update_empty = True): + """Place the object. + + Marks the locations in self._occupied and self._priority + + :param x: + :param y: + :param shape_arr: + :param update_empty: updates the _is_empty, used when adding disallowed areas + """ + x = int(self._scale * x) y = int(self._scale * y) offset_x = x + self._offset_x + shape_arr.offset_x diff --git a/cura/Arranging/ArrangeObjectsAllBuildPlatesJob.py b/cura/Arranging/ArrangeObjectsAllBuildPlatesJob.py index 7736efbeeb..0f337a229b 100644 --- a/cura/Arranging/ArrangeObjectsAllBuildPlatesJob.py +++ b/cura/Arranging/ArrangeObjectsAllBuildPlatesJob.py @@ -18,8 +18,9 @@ from cura.Arranging.ShapeArray import ShapeArray from typing import List -## Do arrangements on multiple build plates (aka builtiplexer) class ArrangeArray: + """Do arrangements on multiple build plates (aka builtiplexer)""" + def __init__(self, x: int, y: int, fixed_nodes: List[SceneNode]) -> None: self._x = x self._y = y diff --git a/cura/Arranging/ArrangeObjectsJob.py b/cura/Arranging/ArrangeObjectsJob.py index aef051c838..46b1aa2d71 100644 --- a/cura/Arranging/ArrangeObjectsJob.py +++ b/cura/Arranging/ArrangeObjectsJob.py @@ -1,22 +1,16 @@ # Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import List from UM.Application import Application from UM.Job import Job -from UM.Scene.SceneNode import SceneNode -from UM.Math.Vector import Vector -from UM.Operations.TranslateOperation import TranslateOperation -from UM.Operations.GroupedOperation import GroupedOperation from UM.Logger import Logger from UM.Message import Message +from UM.Scene.SceneNode import SceneNode from UM.i18n import i18nCatalog -i18n_catalog = i18nCatalog("cura") +from cura.Arranging.Nest2DArrange import arrange -from cura.Scene.ZOffsetDecorator import ZOffsetDecorator -from cura.Arranging.Arrange import Arrange -from cura.Arranging.ShapeArray import ShapeArray - -from typing import List +i18n_catalog = i18nCatalog("cura") class ArrangeObjectsJob(Job): @@ -29,79 +23,22 @@ class ArrangeObjectsJob(Job): def run(self): status_message = Message(i18n_catalog.i18nc("@info:status", "Finding new location for objects"), lifetime = 0, - dismissable=False, + dismissable = False, progress = 0, title = i18n_catalog.i18nc("@info:title", "Finding Location")) status_message.show() - global_container_stack = Application.getInstance().getGlobalContainerStack() - machine_width = global_container_stack.getProperty("machine_width", "value") - machine_depth = global_container_stack.getProperty("machine_depth", "value") - - arranger = Arrange.create(x = machine_width, y = machine_depth, fixed_nodes = self._fixed_nodes, min_offset = self._min_offset) - # Build set to exclude children (those get arranged together with the parents). - included_as_child = set() - for node in self._nodes: - included_as_child.update(node.getAllChildren()) - - # Collect nodes to be placed - nodes_arr = [] # fill with (size, node, offset_shape_arr, hull_shape_arr) - for node in self._nodes: - if node in included_as_child: - continue - offset_shape_arr, hull_shape_arr = ShapeArray.fromNode(node, min_offset = self._min_offset, include_children = True) - if offset_shape_arr is None: - Logger.log("w", "Node [%s] could not be converted to an array for arranging...", str(node)) - continue - nodes_arr.append((offset_shape_arr.arr.shape[0] * offset_shape_arr.arr.shape[1], node, offset_shape_arr, hull_shape_arr)) - - # Sort the nodes with the biggest area first. - nodes_arr.sort(key=lambda item: item[0]) - nodes_arr.reverse() - - # Place nodes one at a time - start_priority = 0 - last_priority = start_priority - last_size = None - grouped_operation = GroupedOperation() - found_solution_for_all = True - not_fit_count = 0 - for idx, (size, node, offset_shape_arr, hull_shape_arr) in enumerate(nodes_arr): - # For performance reasons, we assume that when a location does not fit, - # it will also not fit for the next object (while what can be untrue). - if last_size == size: # This optimization works if many of the objects have the same size - start_priority = last_priority - else: - start_priority = 0 - best_spot = arranger.bestSpot(hull_shape_arr, start_prio = start_priority) - x, y = best_spot.x, best_spot.y - node.removeDecorator(ZOffsetDecorator) - if node.getBoundingBox(): - center_y = node.getWorldPosition().y - node.getBoundingBox().bottom - else: - center_y = 0 - if x is not None: # We could find a place - last_size = size - last_priority = best_spot.priority - - arranger.place(x, y, offset_shape_arr) # take place before the next one - grouped_operation.addOperation(TranslateOperation(node, Vector(x, center_y, y), set_position = True)) - else: - Logger.log("d", "Arrange all: could not find spot!") - found_solution_for_all = False - grouped_operation.addOperation(TranslateOperation(node, Vector(200, center_y, -not_fit_count * 20), set_position = True)) - not_fit_count += 1 - - status_message.setProgress((idx + 1) / len(nodes_arr) * 100) - Job.yieldThread() - - grouped_operation.push() + found_solution_for_all = None + try: + found_solution_for_all = arrange(self._nodes, Application.getInstance().getBuildVolume(), self._fixed_nodes) + except: # If the thread crashes, the message should still close + Logger.logException("e", "Unable to arrange the objects on the buildplate. The arrange algorithm has crashed.") status_message.hide() - - if not found_solution_for_all: - no_full_solution_message = Message(i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"), - title = i18n_catalog.i18nc("@info:title", "Can't Find Location")) + if found_solution_for_all is not None and not found_solution_for_all: + no_full_solution_message = Message( + i18n_catalog.i18nc("@info:status", + "Unable to find a location within the build volume for all objects"), + title = i18n_catalog.i18nc("@info:title", "Can't Find Location")) no_full_solution_message.show() - self.finished.emit(self) diff --git a/cura/Arranging/Nest2DArrange.py b/cura/Arranging/Nest2DArrange.py new file mode 100644 index 0000000000..cdf590232c --- /dev/null +++ b/cura/Arranging/Nest2DArrange.py @@ -0,0 +1,147 @@ +# Copyright (c) 2020 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import numpy +from pynest2d import Point, Box, Item, NfpConfig, nest +from typing import List, TYPE_CHECKING, Optional, Tuple + +from UM.Application import Application +from UM.Logger import Logger +from UM.Math.Matrix import Matrix +from UM.Math.Polygon import Polygon +from UM.Math.Quaternion import Quaternion +from UM.Math.Vector import Vector +from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation +from UM.Operations.GroupedOperation import GroupedOperation +from UM.Operations.RotateOperation import RotateOperation +from UM.Operations.TranslateOperation import TranslateOperation + + +if TYPE_CHECKING: + from UM.Scene.SceneNode import SceneNode + from cura.BuildVolume import BuildVolume + + +def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildVolume", fixed_nodes: Optional[List["SceneNode"]] = None, factor = 10000) -> Tuple[bool, List[Item]]: + """ + Find placement for a set of scene nodes, but don't actually move them just yet. + :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 accurate we want it to be. + + :return: tuple (found_solution_for_all, node_items) + WHERE + found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects + node_items: A list of the nodes return by libnest2d, which contain the new positions on the buildplate + """ + + machine_width = build_volume.getWidth() + machine_depth = build_volume.getDepth() + build_plate_bounding_box = Box(machine_width * factor, machine_depth * factor) + + if fixed_nodes is None: + fixed_nodes = [] + + # Add all the items we want to arrange + node_items = [] + for node in nodes_to_arrange: + hull_polygon = node.callDecoration("getConvexHull") + if not hull_polygon or hull_polygon.getPoints is None: + Logger.log("w", "Object {} cannot be arranged because it has no convex hull.".format(node.getName())) + continue + converted_points = [] + for point in hull_polygon.getPoints(): + converted_points.append(Point(int(point[0] * factor), int(point[1] * factor))) + item = Item(converted_points) + node_items.append(item) + + # Use a tiny margin for the build_plate_polygon (the nesting doesn't like overlapping disallowed areas) + half_machine_width = 0.5 * machine_width - 1 + half_machine_depth = 0.5 * machine_depth - 1 + build_plate_polygon = Polygon(numpy.array([ + [half_machine_width, -half_machine_depth], + [-half_machine_width, -half_machine_depth], + [-half_machine_width, half_machine_depth], + [half_machine_width, half_machine_depth] + ], numpy.float32)) + + disallowed_areas = build_volume.getDisallowedAreas() + num_disallowed_areas_added = 0 + for area in disallowed_areas: + converted_points = [] + + # Clip the disallowed areas so that they don't overlap the bounding box (The arranger chokes otherwise) + clipped_area = area.intersectionConvexHulls(build_plate_polygon) + + if clipped_area.getPoints() is not None: # numpy array has to be explicitly checked against None + for point in clipped_area.getPoints(): + converted_points.append(Point(int(point[0] * factor), int(point[1] * factor))) + + disallowed_area = Item(converted_points) + disallowed_area.markAsDisallowedAreaInBin(0) + node_items.append(disallowed_area) + num_disallowed_areas_added += 1 + + for node in fixed_nodes: + converted_points = [] + hull_polygon = node.callDecoration("getConvexHull") + + if hull_polygon is not None and hull_polygon.getPoints() is not None: # numpy array has to be explicitly checked against None + for point in hull_polygon.getPoints(): + converted_points.append(Point(point[0] * factor, point[1] * factor)) + item = Item(converted_points) + item.markAsFixedInBin(0) + node_items.append(item) + num_disallowed_areas_added += 1 + + config = NfpConfig() + config.accuracy = 1.0 + + num_bins = nest(node_items, build_plate_bounding_box, 10000, config) + + # Strip the fixed items (previously placed) and the disallowed areas from the results again. + node_items = list(filter(lambda item: not item.isFixed(), node_items)) + + found_solution_for_all = num_bins == 1 + + return found_solution_for_all, node_items + + +def arrange(nodes_to_arrange: List["SceneNode"], build_volume: "BuildVolume", fixed_nodes: Optional[List["SceneNode"]] = None, factor = 10000, add_new_nodes_in_scene: bool = False) -> bool: + """ + Find placement for a set of scene nodes, and move them by using a single grouped operation. + :param nodes_to_arrange: The list of nodes that need to be moved. + :param build_volume: The build volume that we want to place the nodes in. It gets size & disallowed areas from this. + :param fixed_nodes: List of nods that should not be moved, but should be used when deciding where the others nodes + are placed. + :param factor: The library that we use is int based. This factor defines how accuracte we want it to be. + :param add_new_nodes_in_scene: Whether to create new scene nodes before applying the transformations and rotations + + :return: found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects + """ + scene_root = Application.getInstance().getController().getScene().getRoot() + found_solution_for_all, node_items = findNodePlacement(nodes_to_arrange, build_volume, fixed_nodes, factor) + + not_fit_count = 0 + grouped_operation = GroupedOperation() + for node, node_item in zip(nodes_to_arrange, node_items): + if add_new_nodes_in_scene: + grouped_operation.addOperation(AddSceneNodeOperation(node, scene_root)) + + if node_item.binId() == 0: + # We found a spot for it + rotation_matrix = Matrix() + rotation_matrix.setByRotationAxis(node_item.rotation(), Vector(0, -1, 0)) + grouped_operation.addOperation(RotateOperation(node, Quaternion.fromMatrix(rotation_matrix))) + grouped_operation.addOperation(TranslateOperation(node, Vector(node_item.translation().x() / factor, 0, + node_item.translation().y() / factor))) + else: + # We didn't find a spot + grouped_operation.addOperation( + TranslateOperation(node, Vector(200, node.getWorldPosition().y, -not_fit_count * 20), set_position = True)) + not_fit_count += 1 + grouped_operation.push() + + return found_solution_for_all diff --git a/cura/Arranging/ShapeArray.py b/cura/Arranging/ShapeArray.py index 403db5e706..840f9731c2 100644 --- a/cura/Arranging/ShapeArray.py +++ b/cura/Arranging/ShapeArray.py @@ -11,19 +11,24 @@ if TYPE_CHECKING: from UM.Scene.SceneNode import SceneNode -## Polygon representation as an array for use with Arrange class ShapeArray: + """Polygon representation as an array for use with :py:class:`cura.Arranging.Arrange.Arrange`""" + def __init__(self, arr: numpy.array, offset_x: float, offset_y: float, scale: float = 1) -> None: self.arr = arr self.offset_x = offset_x self.offset_y = offset_y self.scale = scale - ## Instantiate from a bunch of vertices - # \param vertices - # \param scale scale the coordinates @classmethod def fromPolygon(cls, vertices: numpy.array, scale: float = 1) -> "ShapeArray": + """Instantiate from a bunch of vertices + + :param vertices: + :param scale: scale the coordinates + :return: a shape array instantiated from a bunch of vertices + """ + # scale vertices = vertices * scale # flip y, x -> x, y @@ -44,12 +49,16 @@ class ShapeArray: arr[0][0] = 1 return cls(arr, offset_x, offset_y) - ## Instantiate an offset and hull ShapeArray from a scene node. - # \param node source node where the convex hull must be present - # \param min_offset offset for the offset ShapeArray - # \param scale scale the coordinates @classmethod def fromNode(cls, node: "SceneNode", min_offset: float, scale: float = 0.5, include_children: bool = False) -> Tuple[Optional["ShapeArray"], Optional["ShapeArray"]]: + """Instantiate an offset and hull ShapeArray from a scene node. + + :param node: source node where the convex hull must be present + :param min_offset: offset for the offset ShapeArray + :param scale: scale the coordinates + :return: A tuple containing an offset and hull shape array + """ + transform = node._transformation transform_x = transform._data[0][3] transform_y = transform._data[2][3] @@ -88,14 +97,19 @@ class ShapeArray: return offset_shape_arr, hull_shape_arr - ## Create np.array with dimensions defined by shape - # Fills polygon defined by vertices with ones, all other values zero - # Only works correctly for convex hull vertices - # Originally from: http://stackoverflow.com/questions/37117878/generating-a-filled-polygon-inside-a-numpy-array - # \param shape numpy format shape, [x-size, y-size] - # \param vertices @classmethod def arrayFromPolygon(cls, shape: Tuple[int, int], vertices: numpy.array) -> numpy.array: + """Create :py:class:`numpy.ndarray` with dimensions defined by shape + + Fills polygon defined by vertices with ones, all other values zero + Only works correctly for convex hull vertices + Originally from: `Stackoverflow - generating a filled polygon inside a numpy array <https://stackoverflow.com/questions/37117878/generating-a-filled-polygon-inside-a-numpy-array>`_ + + :param shape: numpy format shape, [x-size, y-size] + :param vertices: + :return: numpy array with dimensions defined by shape + """ + base_array = numpy.zeros(shape, dtype = numpy.int32) # Initialize your array of zeros fill = numpy.ones(base_array.shape) * True # Initialize boolean array defining shape fill @@ -111,16 +125,21 @@ class ShapeArray: return base_array - ## Return indices that mark one side of the line, used by arrayFromPolygon - # Uses the line defined by p1 and p2 to check array of - # input indices against interpolated value - # Returns boolean array, with True inside and False outside of shape - # Originally from: http://stackoverflow.com/questions/37117878/generating-a-filled-polygon-inside-a-numpy-array - # \param p1 2-tuple with x, y for point 1 - # \param p2 2-tuple with x, y for point 2 - # \param base_array boolean array to project the line on @classmethod def _check(cls, p1: numpy.array, p2: numpy.array, base_array: numpy.array) -> Optional[numpy.array]: + """Return indices that mark one side of the line, used by arrayFromPolygon + + Uses the line defined by p1 and p2 to check array of + input indices against interpolated value + Returns boolean array, with True inside and False outside of shape + Originally from: `Stackoverflow - generating a filled polygon inside a numpy array <https://stackoverflow.com/questions/37117878/generating-a-filled-polygon-inside-a-numpy-array>`_ + + :param p1: 2-tuple with x, y for point 1 + :param p2: 2-tuple with x, y for point 2 + :param base_array: boolean array to project the line on + :return: A numpy array with indices that mark one side of the line + """ + if p1[0] == p2[0] and p1[1] == p2[1]: return None idxs = numpy.indices(base_array.shape) # Create 3D array of indices diff --git a/cura/AutoSave.py b/cura/AutoSave.py index 2c1dbe4a84..d80e34771e 100644 --- a/cura/AutoSave.py +++ b/cura/AutoSave.py @@ -31,7 +31,6 @@ class AutoSave: self._change_timer.timeout.connect(self._onTimeout) self._application.globalContainerStackChanged.connect(self._onGlobalStackChanged) self._onGlobalStackChanged() - self._triggerTimer() def _triggerTimer(self, *args: Any) -> None: if not self._saving: diff --git a/cura/Backups/Backup.py b/cura/Backups/Backup.py index 4d24a46384..011eb97310 100644 --- a/cura/Backups/Backup.py +++ b/cura/Backups/Backup.py @@ -18,24 +18,26 @@ if TYPE_CHECKING: from cura.CuraApplication import CuraApplication -## The back-up class holds all data about a back-up. -# -# It is also responsible for reading and writing the zip file to the user data -# folder. class Backup: - # These files should be ignored when making a backup. + """The back-up class holds all data about a back-up. + + It is also responsible for reading and writing the zip file to the user data folder. + """ + IGNORED_FILES = [r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc"] + """These files should be ignored when making a backup.""" - # Re-use translation catalog. catalog = i18nCatalog("cura") + """Re-use translation catalog""" def __init__(self, application: "CuraApplication", zip_file: bytes = None, meta_data: Dict[str, str] = None) -> None: self._application = application self.zip_file = zip_file # type: Optional[bytes] self.meta_data = meta_data # type: Optional[Dict[str, str]] - ## Create a back-up from the current user config folder. def makeFromCurrent(self) -> None: + """Create a back-up from the current user config folder.""" + cura_release = self._application.getVersion() version_data_dir = Resources.getDataStoragePath() @@ -62,11 +64,11 @@ class Backup: files = archive.namelist() # Count the metadata items. We do this in a rather naive way at the moment. - machine_count = len([s for s in files if "machine_instances/" in s]) - 1 - material_count = len([s for s in files if "materials/" in s]) - 1 - profile_count = len([s for s in files if "quality_changes/" in s]) - 1 + machine_count = max(len([s for s in files if "machine_instances/" in s]) - 1, 0) # If people delete their profiles but not their preferences, it can still make a backup, and report -1 profiles. Server crashes on this. + material_count = max(len([s for s in files if "materials/" in s]) - 1, 0) + profile_count = max(len([s for s in files if "quality_changes/" in s]) - 1, 0) plugin_count = len([s for s in files if "plugin.json" in s]) - + # Store the archive and metadata so the BackupManager can fetch them when needed. self.zip_file = buffer.getvalue() self.meta_data = { @@ -77,10 +79,13 @@ class Backup: "plugin_count": str(plugin_count) } - ## Make a full archive from the given root path with the given name. - # \param root_path The root directory to archive recursively. - # \return The archive as bytes. def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]: + """Make a full archive from the given root path with the given name. + + :param root_path: The root directory to archive recursively. + :return: The archive as bytes. + """ + ignore_string = re.compile("|".join(self.IGNORED_FILES)) try: archive = ZipFile(buffer, "w", ZIP_DEFLATED) @@ -99,13 +104,17 @@ class Backup: "Could not create archive from user data directory: {}".format(error))) return None - ## Show a UI message. def _showMessage(self, message: str) -> None: + """Show a UI message.""" + Message(message, title=self.catalog.i18nc("@info:title", "Backup"), lifetime=30).show() - ## Restore this back-up. - # \return Whether we had success or not. def restore(self) -> bool: + """Restore this back-up. + + :return: Whether we had success or not. + """ + if not self.zip_file or not self.meta_data or not self.meta_data.get("cura_release", None): # We can restore without the minimum required information. Logger.log("w", "Tried to restore a Cura backup without having proper data or meta data.") @@ -139,12 +148,14 @@ class Backup: return extracted - ## Extract the whole archive to the given target path. - # \param archive The archive as ZipFile. - # \param target_path The target path. - # \return Whether we had success or not. @staticmethod def _extractArchive(archive: "ZipFile", target_path: str) -> bool: + """Extract the whole archive to the given target path. + + :param archive: The archive as ZipFile. + :param target_path: The target path. + :return: Whether we had success or not. + """ # Implement security recommendations: Sanity check on zip files will make it harder to spoof. from cura.CuraApplication import CuraApplication @@ -158,7 +169,7 @@ class Backup: Logger.log("d", "Extracting backup to location: %s", target_path) try: archive.extractall(target_path) - except PermissionError: - Logger.logException("e", "Unable to extract the backup due to permission errors") + except (PermissionError, EnvironmentError): + Logger.logException("e", "Unable to extract the backup due to permission or file system errors.") return False return True diff --git a/cura/Backups/BackupsManager.py b/cura/Backups/BackupsManager.py index ba6fcab8d7..fb758455c1 100644 --- a/cura/Backups/BackupsManager.py +++ b/cura/Backups/BackupsManager.py @@ -10,18 +10,24 @@ if TYPE_CHECKING: from cura.CuraApplication import CuraApplication -## The BackupsManager is responsible for managing the creating and restoring of -# back-ups. -# -# Back-ups themselves are represented in a different class. class BackupsManager: + """ + The BackupsManager is responsible for managing the creating and restoring of + back-ups. + + Back-ups themselves are represented in a different class. + """ + def __init__(self, application: "CuraApplication") -> None: self._application = application - ## Get a back-up of the current configuration. - # \return A tuple containing a ZipFile (the actual back-up) and a dict - # containing some metadata (like version). def createBackup(self) -> Tuple[Optional[bytes], Optional[Dict[str, str]]]: + """ + Get a back-up of the current configuration. + + :return: A tuple containing a ZipFile (the actual back-up) and a dict containing some metadata (like version). + """ + self._disableAutoSave() backup = Backup(self._application) backup.makeFromCurrent() @@ -29,11 +35,14 @@ class BackupsManager: # We don't return a Backup here because we want plugins only to interact with our API and not full objects. return backup.zip_file, backup.meta_data - ## Restore a back-up from a given ZipFile. - # \param zip_file A bytes object containing the actual back-up. - # \param meta_data A dict containing some metadata that is needed to - # restore the back-up correctly. def restoreBackup(self, zip_file: bytes, meta_data: Dict[str, str]) -> None: + """ + Restore a back-up from a given ZipFile. + + :param zip_file: A bytes object containing the actual back-up. + :param meta_data: A dict containing some metadata that is needed to restore the back-up correctly. + """ + if not meta_data.get("cura_release", None): # If there is no "cura_release" specified in the meta data, we don't execute a backup restore. Logger.log("w", "Tried to restore a backup without specifying a Cura version number.") @@ -48,9 +57,10 @@ class BackupsManager: # We don't want to store the data at this point as that would override the just-restored backup. self._application.windowClosed(save_data = False) - ## Here we try to disable the auto-save plug-in as it might interfere with - # restoring a back-up. def _disableAutoSave(self) -> None: + """Here we (try to) disable the saving as it might interfere with restoring a back-up.""" + + self._application.enableSave(False) auto_save = self._application.getAutoSave() # The auto save is only not created if the application has not yet started. if auto_save: @@ -58,8 +68,10 @@ class BackupsManager: else: Logger.log("e", "Unable to disable the autosave as application init has not been completed") - ## Re-enable auto-save after we're done. def _enableAutoSave(self) -> None: + """Re-enable auto-save and other saving after we're done.""" + + self._application.enableSave(True) auto_save = self._application.getAutoSave() # The auto save is only not created if the application has not yet started. if auto_save: diff --git a/cura/BuildVolume.py b/cura/BuildVolume.py index d7ab18b09e..6bda2d94e3 100755 --- a/cura/BuildVolume.py +++ b/cura/BuildVolume.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import numpy @@ -44,8 +44,9 @@ catalog = i18nCatalog("cura") PRIME_CLEARANCE = 6.5 -## Build volume is a special kind of node that is responsible for rendering the printable area & disallowed areas. class BuildVolume(SceneNode): + """Build volume is a special kind of node that is responsible for rendering the printable area & disallowed areas.""" + raftThicknessChanged = Signal() def __init__(self, application: "CuraApplication", parent: Optional[SceneNode] = None) -> None: @@ -91,6 +92,8 @@ class BuildVolume(SceneNode): self._adhesion_type = None # type: Any self._platform = Platform(self) + self._edge_disallowed_size = None + self._build_volume_message = Message(catalog.i18nc("@info:status", "The build volume height has been reduced due to the value of the" " \"Print Sequence\" setting to prevent the gantry from colliding" @@ -105,19 +108,17 @@ class BuildVolume(SceneNode): self._application.globalContainerStackChanged.connect(self._onStackChanged) - self._onStackChanged() - self._engine_ready = False self._application.engineCreatedSignal.connect(self._onEngineCreated) self._has_errors = False self._application.getController().getScene().sceneChanged.connect(self._onSceneChanged) - #Objects loaded at the moment. We are connected to the property changed events of these objects. + # Objects loaded at the moment. We are connected to the property changed events of these objects. self._scene_objects = set() # type: Set[SceneNode] self._scene_change_timer = QTimer() - self._scene_change_timer.setInterval(100) + self._scene_change_timer.setInterval(200) self._scene_change_timer.setSingleShot(True) self._scene_change_timer.timeout.connect(self._onSceneChangeTimerFinished) @@ -163,10 +164,12 @@ class BuildVolume(SceneNode): self._scene_objects = new_scene_objects self._onSettingPropertyChanged("print_sequence", "value") # Create fake event, so right settings are triggered. - ## Updates the listeners that listen for changes in per-mesh stacks. - # - # \param node The node for which the decorators changed. def _updateNodeListeners(self, node: SceneNode): + """Updates the listeners that listen for changes in per-mesh stacks. + + :param node: The node for which the decorators changed. + """ + per_mesh_stack = node.callDecoration("getStack") if per_mesh_stack: per_mesh_stack.propertyChanged.connect(self._onSettingPropertyChanged) @@ -177,20 +180,33 @@ class BuildVolume(SceneNode): def setWidth(self, width: float) -> None: self._width = width + def getWidth(self) -> float: + return self._width + def setHeight(self, height: float) -> None: self._height = height + def getHeight(self) -> float: + return self._height + def setDepth(self, depth: float) -> None: self._depth = depth + def getDepth(self) -> float: + return self._depth + def setShape(self, shape: str) -> None: if shape: self._shape = shape - ## Get the length of the 3D diagonal through the build volume. - # - # This gives a sense of the scale of the build volume in general. def getDiagonalSize(self) -> float: + """Get the length of the 3D diagonal through the build volume. + + This gives a sense of the scale of the build volume in general. + + :return: length of the 3D diagonal through the build volume + """ + return math.sqrt(self._width * self._width + self._height * self._height + self._depth * self._depth) def getDisallowedAreas(self) -> List[Polygon]: @@ -226,9 +242,9 @@ class BuildVolume(SceneNode): return True - ## For every sliceable node, update node._outside_buildarea - # def updateNodeBoundaryCheck(self): + """For every sliceable node, update node._outside_buildarea""" + if not self._global_container_stack: return @@ -265,7 +281,7 @@ class BuildVolume(SceneNode): continue # If the entire node is below the build plate, still mark it as outside. node_bounding_box = node.getBoundingBox() - if node_bounding_box and node_bounding_box.top < 0: + if node_bounding_box and node_bounding_box.top < 0 and not node.getParent().callDecoration("isGroup"): node.setOutsideBuildArea(True) continue # Mark the node as outside build volume if the set extruder is disabled @@ -274,7 +290,9 @@ class BuildVolume(SceneNode): if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled: node.setOutsideBuildArea(True) continue - except IndexError: + except IndexError: # Happens when the extruder list is too short. We're not done building the printer in memory yet. + continue + except TypeError: # Happens when extruder_position is None. This object has no extruder decoration. continue node.setOutsideBuildArea(False) @@ -293,8 +311,13 @@ class BuildVolume(SceneNode): for child_node in children: child_node.setOutsideBuildArea(group_node.isOutsideBuildArea()) - ## Update the outsideBuildArea of a single node, given bounds or current build volume def checkBoundsAndUpdate(self, node: CuraSceneNode, bounds: Optional[AxisAlignedBox] = None) -> None: + """Update the outsideBuildArea of a single node, given bounds or current build volume + + :param node: single node + :param bounds: bounds or current build volume + """ + if not isinstance(node, CuraSceneNode) or self._global_container_stack is None: return @@ -321,7 +344,12 @@ class BuildVolume(SceneNode): # Mark the node as outside build volume if the set extruder is disabled extruder_position = node.callDecoration("getActiveExtruderPosition") - if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled: + try: + if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled: + node.setOutsideBuildArea(True) + return + except IndexError: + # If the extruder doesn't exist, also mark it as unprintable. node.setOutsideBuildArea(True) return @@ -482,8 +510,9 @@ class BuildVolume(SceneNode): self._disallowed_area_size = max(size, self._disallowed_area_size) return mb.build() - ## Recalculates the build volume & disallowed areas. def rebuild(self) -> None: + """Recalculates the build volume & disallowed areas.""" + if not self._width or not self._height or not self._depth: return @@ -572,7 +601,7 @@ class BuildVolume(SceneNode): def _calculateExtraZClearance(self, extruders: List["ContainerStack"]) -> float: if not self._global_container_stack: return 0 - + extra_z = 0.0 for extruder in extruders: if extruder.getProperty("retraction_hop_enabled", "value"): @@ -584,8 +613,9 @@ class BuildVolume(SceneNode): def _onStackChanged(self): self._stack_change_timer.start() - ## Update the build volume visualization def _onStackChangeTimerFinished(self) -> None: + """Update the build volume visualization""" + if self._global_container_stack: self._global_container_stack.propertyChanged.disconnect(self._onSettingPropertyChanged) extruders = ExtruderManager.getInstance().getActiveExtruderStacks() @@ -710,15 +740,15 @@ class BuildVolume(SceneNode): self._depth = self._global_container_stack.getProperty("machine_depth", "value") self._shape = self._global_container_stack.getProperty("machine_shape", "value") - ## Calls _updateDisallowedAreas and makes sure the changes appear in the - # scene. - # - # This is required for a signal to trigger the update in one go. The - # ``_updateDisallowedAreas`` method itself shouldn't call ``rebuild``, - # since there may be other changes before it needs to be rebuilt, which - # would hit performance. - def _updateDisallowedAreasAndRebuild(self): + """Calls :py:meth:`cura.BuildVolume._updateDisallowedAreas` and makes sure the changes appear in the scene. + + This is required for a signal to trigger the update in one go. The + :py:meth:`cura.BuildVolume._updateDisallowedAreas` method itself shouldn't call + :py:meth:`cura.BuildVolume.rebuild`, since there may be other changes before it needs to be rebuilt, + which would hit performance. + """ + self._updateDisallowedAreas() self._updateRaftThickness() self._extra_z_clearance = self._calculateExtraZClearance(ExtruderManager.getInstance().getUsedExtruderStacks()) @@ -731,6 +761,7 @@ class BuildVolume(SceneNode): self._error_areas = [] used_extruders = ExtruderManager.getInstance().getUsedExtruderStacks() + self._edge_disallowed_size = None # Force a recalculation disallowed_border_size = self.getEdgeDisallowedSize() result_areas = self._computeDisallowedAreasStatic(disallowed_border_size, used_extruders) # Normal machine disallowed areas can always be added. @@ -764,7 +795,10 @@ class BuildVolume(SceneNode): if prime_tower_collision: # Already found a collision. break if self._global_container_stack.getProperty("prime_tower_brim_enable", "value") and self._global_container_stack.getProperty("adhesion_type", "value") != "raft": - prime_tower_areas[extruder_id][area_index] = prime_tower_area.getMinkowskiHull(Polygon.approximatedCircle(disallowed_border_size)) + brim_size = self._calculateBedAdhesionSize(used_extruders, "brim") + # Use 2x the brim size, since we need 1x brim size distance due to the object brim and another + # times the brim due to the brim of the prime tower + prime_tower_areas[extruder_id][area_index] = prime_tower_area.getMinkowskiHull(Polygon.approximatedCircle(2 * brim_size, num_segments = 24)) if not prime_tower_collision: result_areas[extruder_id].extend(prime_tower_areas[extruder_id]) result_areas_no_brim[extruder_id].extend(prime_tower_areas[extruder_id]) @@ -780,15 +814,14 @@ class BuildVolume(SceneNode): for extruder_id in result_areas_no_brim: self._disallowed_areas_no_brim.extend(result_areas_no_brim[extruder_id]) - ## Computes the disallowed areas for objects that are printed with print - # features. - # - # This means that the brim, travel avoidance and such will be applied to - # these features. - # - # \return A dictionary with for each used extruder ID the disallowed areas - # where that extruder may not print. def _computeDisallowedAreasPrinted(self, used_extruders): + """Computes the disallowed areas for objects that are printed with print features. + + This means that the brim, travel avoidance and such will be applied to these features. + + :return: A dictionary with for each used extruder ID the disallowed areas where that extruder may not print. + """ + result = {} adhesion_extruder = None #type: ExtruderStack for extruder in used_extruders: @@ -817,7 +850,7 @@ class BuildVolume(SceneNode): prime_tower_y += brim_size radius = prime_tower_size / 2 - prime_tower_area = Polygon.approximatedCircle(radius) + prime_tower_area = Polygon.approximatedCircle(radius, num_segments = 24) prime_tower_area = prime_tower_area.translate(prime_tower_x - radius, prime_tower_y - radius) prime_tower_area = prime_tower_area.getMinkowskiHull(Polygon.approximatedCircle(0)) @@ -826,18 +859,18 @@ class BuildVolume(SceneNode): return result - ## Computes the disallowed areas for the prime blobs. - # - # These are special because they are not subject to things like brim or - # travel avoidance. They do get a dilute with the border size though - # because they may not intersect with brims and such of other objects. - # - # \param border_size The size with which to offset the disallowed areas - # due to skirt, brim, travel avoid distance, etc. - # \param used_extruders The extruder stacks to generate disallowed areas - # for. - # \return A dictionary with for each used extruder ID the prime areas. def _computeDisallowedAreasPrimeBlob(self, border_size: float, used_extruders: List["ExtruderStack"]) -> Dict[str, List[Polygon]]: + """Computes the disallowed areas for the prime blobs. + + These are special because they are not subject to things like brim or travel avoidance. They do get a dilute + with the border size though because they may not intersect with brims and such of other objects. + + :param border_size: The size with which to offset the disallowed areas due to skirt, brim, travel avoid distance + , etc. + :param used_extruders: The extruder stacks to generate disallowed areas for. + :return: A dictionary with for each used extruder ID the prime areas. + """ + result = {} # type: Dict[str, List[Polygon]] if not self._global_container_stack: return result @@ -865,19 +898,18 @@ class BuildVolume(SceneNode): return result - ## Computes the disallowed areas that are statically placed in the machine. - # - # It computes different disallowed areas depending on the offset of the - # extruder. The resulting dictionary will therefore have an entry for each - # extruder that is used. - # - # \param border_size The size with which to offset the disallowed areas - # due to skirt, brim, travel avoid distance, etc. - # \param used_extruders The extruder stacks to generate disallowed areas - # for. - # \return A dictionary with for each used extruder ID the disallowed areas - # where that extruder may not print. def _computeDisallowedAreasStatic(self, border_size:float, used_extruders: List["ExtruderStack"]) -> Dict[str, List[Polygon]]: + """Computes the disallowed areas that are statically placed in the machine. + + It computes different disallowed areas depending on the offset of the extruder. The resulting dictionary will + therefore have an entry for each extruder that is used. + + :param border_size: The size with which to offset the disallowed areas due to skirt, brim, travel avoid distance + , etc. + :param used_extruders: The extruder stacks to generate disallowed areas for. + :return: A dictionary with for each used extruder ID the disallowed areas where that extruder may not print. + """ + # Convert disallowed areas to polygons and dilate them. machine_disallowed_polygons = [] if self._global_container_stack is None: @@ -1008,13 +1040,14 @@ class BuildVolume(SceneNode): return result - ## Private convenience function to get a setting from every extruder. - # - # For single extrusion machines, this gets the setting from the global - # stack. - # - # \return A sequence of setting values, one for each extruder. def _getSettingFromAllExtruders(self, setting_key: str) -> List[Any]: + """Private convenience function to get a setting from every extruder. + + For single extrusion machines, this gets the setting from the global stack. + + :return: A sequence of setting values, one for each extruder. + """ + all_values = ExtruderManager.getInstance().getAllExtruderSettings(setting_key, "value") all_types = ExtruderManager.getInstance().getAllExtruderSettings(setting_key, "type") for i, (setting_value, setting_type) in enumerate(zip(all_values, all_types)): @@ -1022,16 +1055,23 @@ class BuildVolume(SceneNode): all_values[i] = 0 return all_values - def _calculateBedAdhesionSize(self, used_extruders): + def _calculateBedAdhesionSize(self, used_extruders, adhesion_override = None): + """Get the bed adhesion size for the global container stack and used extruders + + :param adhesion_override: override adhesion type. + Use None to use the global stack default, "none" for no adhesion, "brim" for brim etc. + """ if self._global_container_stack is None: return None container_stack = self._global_container_stack - adhesion_type = container_stack.getProperty("adhesion_type", "value") + adhesion_type = adhesion_override + if adhesion_type is None: + adhesion_type = container_stack.getProperty("adhesion_type", "value") skirt_brim_line_width = self._global_container_stack.getProperty("skirt_brim_line_width", "value") initial_layer_line_width_factor = self._global_container_stack.getProperty("initial_layer_line_width_factor", "value") # Use brim width if brim is enabled OR the prime tower has a brim. - if adhesion_type == "brim" or (self._global_container_stack.getProperty("prime_tower_brim_enable", "value") and adhesion_type != "raft"): + if adhesion_type == "brim": brim_line_count = self._global_container_stack.getProperty("brim_line_count", "value") bed_adhesion_size = skirt_brim_line_width * brim_line_count * initial_layer_line_width_factor / 100.0 @@ -1040,7 +1080,7 @@ class BuildVolume(SceneNode): # 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 - elif adhesion_type == "skirt": # No brim? Also not on prime tower? Then use whatever the adhesion type is saying: Skirt, raft or none. + elif adhesion_type == "skirt": skirt_distance = self._global_container_stack.getProperty("skirt_gap", "value") skirt_line_count = self._global_container_stack.getProperty("skirt_line_count", "value") @@ -1084,25 +1124,34 @@ class BuildVolume(SceneNode): def _calculateMoveFromWallRadius(self, used_extruders): move_from_wall_radius = 0 # Moves that start from outer wall. - all_values = [move_from_wall_radius] - all_values.extend(self._getSettingFromAllExtruders("infill_wipe_dist")) - move_from_wall_radius = max(all_values) - avoid_enabled_per_extruder = [stack.getProperty("travel_avoid_other_parts", "value") for stack in used_extruders] - travel_avoid_distance_per_extruder = [stack.getProperty("travel_avoid_distance", "value") for stack in used_extruders] - for avoid_other_parts_enabled, avoid_distance in zip(avoid_enabled_per_extruder, travel_avoid_distance_per_extruder): # For each extruder (or just global). - if avoid_other_parts_enabled: - move_from_wall_radius = max(move_from_wall_radius, avoid_distance) + + for stack in used_extruders: + if stack.getProperty("travel_avoid_other_parts", "value"): + move_from_wall_radius = max(move_from_wall_radius, stack.getProperty("travel_avoid_distance", "value")) + + infill_wipe_distance = stack.getProperty("infill_wipe_dist", "value") + num_walls = stack.getProperty("wall_line_count", "value") + if num_walls >= 1: # Infill wipes start from the infill, so subtract the total wall thickness from this. + infill_wipe_distance -= stack.getProperty("wall_line_width_0", "value") + if num_walls >= 2: + infill_wipe_distance -= stack.getProperty("wall_line_width_x", "value") * (num_walls - 1) + move_from_wall_radius = max(move_from_wall_radius, infill_wipe_distance) + return move_from_wall_radius - ## Calculate the disallowed radius around the edge. - # - # This disallowed radius is to allow for space around the models that is - # not part of the collision radius, such as bed adhesion (skirt/brim/raft) - # and travel avoid distance. def getEdgeDisallowedSize(self): + """Calculate the disallowed radius around the edge. + + This disallowed radius is to allow for space around the models that is not part of the collision radius, + such as bed adhesion (skirt/brim/raft) and travel avoid distance. + """ + if not self._global_container_stack or not self._global_container_stack.extruderList: return 0 + if self._edge_disallowed_size is not None: + return self._edge_disallowed_size + container_stack = self._global_container_stack used_extruders = ExtruderManager.getInstance().getUsedExtruderStacks() @@ -1118,8 +1167,8 @@ class BuildVolume(SceneNode): # Now combine our different pieces of data to get the final border size. # Support expansion is added to the bed adhesion, since the bed adhesion goes around support. # Support expansion is added to farthest shield distance, since the shields go around support. - border_size = max(move_from_wall_radius, support_expansion + farthest_shield_distance, support_expansion + bed_adhesion_size) - return border_size + self._edge_disallowed_size = max(move_from_wall_radius, support_expansion + farthest_shield_distance, support_expansion + bed_adhesion_size) + return self._edge_disallowed_size def _clamp(self, value, min_value, max_value): return max(min(value, max_value), min_value) @@ -1128,10 +1177,10 @@ class BuildVolume(SceneNode): _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"] _extra_z_settings = ["retraction_hop_enabled", "retraction_hop"] - _prime_settings = ["extruder_prime_pos_x", "extruder_prime_pos_y", "extruder_prime_pos_z", "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"] _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"] + _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. _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"] _disallowed_area_settings = _skirt_settings + _prime_settings + _tower_settings + _ooze_shield_settings + _distance_settings + _extruder_settings diff --git a/cura/CrashHandler.py b/cura/CrashHandler.py index e72180887c..db44daa77c 100644 --- a/cura/CrashHandler.py +++ b/cura/CrashHandler.py @@ -10,7 +10,7 @@ import os.path import uuid import json import locale -from typing import cast +from typing import cast, Any try: from sentry_sdk.hub import Hub @@ -32,6 +32,8 @@ from UM.Resources import Resources from cura import ApplicationMetadata catalog = i18nCatalog("cura") +home_dir = os.path.expanduser("~") + MYPY = False if MYPY: @@ -83,6 +85,21 @@ class CrashHandler: self.dialog = QDialog() self._createDialog() + @staticmethod + def pruneSensitiveData(obj: Any) -> Any: + if isinstance(obj, str): + return obj.replace("\\\\", "\\").replace(home_dir, "<user_home>") + if isinstance(obj, list): + return [CrashHandler.pruneSensitiveData(item) for item in obj] + if isinstance(obj, dict): + return {k: CrashHandler.pruneSensitiveData(v) for k, v in obj.items()} + + return obj + + @staticmethod + def sentryBeforeSend(event, hint): + return CrashHandler.pruneSensitiveData(event) + def _createEarlyCrashDialog(self): dialog = QDialog() dialog.setMinimumWidth(500) @@ -133,8 +150,9 @@ class CrashHandler: self._sendCrashReport() os._exit(1) - ## Backup the current resource directories and create clean ones. def _backupAndStartClean(self): + """Backup the current resource directories and create clean ones.""" + Resources.factoryReset() self.early_crash_dialog.close() @@ -145,8 +163,9 @@ class CrashHandler: def _showDetailedReport(self): self.dialog.exec_() - ## Creates a modal dialog. def _createDialog(self): + """Creates a modal dialog.""" + self.dialog.setMinimumWidth(640) self.dialog.setMinimumHeight(640) self.dialog.setWindowTitle(catalog.i18nc("@title:window", "Crash Report")) @@ -161,7 +180,6 @@ class CrashHandler: layout.addWidget(self._informationWidget()) layout.addWidget(self._exceptionInfoWidget()) layout.addWidget(self._logInfoWidget()) - layout.addWidget(self._userDescriptionWidget()) layout.addWidget(self._buttonsWidget()) def _close(self): @@ -197,6 +215,16 @@ class CrashHandler: locale.getdefaultlocale()[0] self.data["locale_cura"] = self.cura_locale + try: + from cura.CuraApplication import CuraApplication + plugins = CuraApplication.getInstance().getPluginRegistry() + self.data["plugins"] = { + plugin_id: plugins.getMetaData(plugin_id)["plugin"]["version"] + for plugin_id in plugins.getInstalledPlugins() if not plugins.isBundledPlugin(plugin_id) + } + except: + self.data["plugins"] = {"[FAILED]": "0.0.0"} + crash_info = "<b>" + catalog.i18nc("@label Cura version number", "Cura version") + ":</b> " + str(self.cura_version) + "<br/>" crash_info += "<b>" + catalog.i18nc("@label", "Cura language") + ":</b> " + str(self.cura_locale) + "<br/>" crash_info += "<b>" + catalog.i18nc("@label", "OS language") + ":</b> " + str(self.data["locale_os"]) + "<br/>" @@ -219,8 +247,13 @@ class CrashHandler: scope.set_tag("locale_os", self.data["locale_os"]) scope.set_tag("locale_cura", self.cura_locale) scope.set_tag("is_enterprise", ApplicationMetadata.IsEnterpriseVersion) - - scope.set_user({"id": str(uuid.getnode())}) + + scope.set_context("plugins", self.data["plugins"]) + + user_id = uuid.getnode() # On all of Cura's supported platforms, this returns the MAC address which is pseudonymical information (!= anonymous). + user_id %= 2 ** 16 # So to make it anonymous, apply a bitmask selecting only the last 16 bits. + # This prevents it from being traceable to a specific user but still gives somewhat of an idea of whether it's just the same user hitting the same crash over and over again, or if it's widespread. + scope.set_user({"id": str(user_id)}) return group @@ -374,21 +407,6 @@ class CrashHandler: return group - def _userDescriptionWidget(self): - group = QGroupBox() - group.setTitle(catalog.i18nc("@title:groupbox", "User description" + - " (Note: Developers may not speak your language, please use English if possible)")) - layout = QVBoxLayout() - - # When sending the report, the user comments will be collected - self.user_description_text_area = QTextEdit() - self.user_description_text_area.setFocus(True) - - layout.addWidget(self.user_description_text_area) - group.setLayout(layout) - - return group - def _buttonsWidget(self): buttons = QDialogButtonBox() buttons.addButton(QDialogButtonBox.Close) @@ -403,9 +421,6 @@ class CrashHandler: return buttons def _sendCrashReport(self): - # Before sending data, the user comments are stored - self.data["user_info"] = self.user_description_text_area.toPlainText() - if with_sentry_sdk: try: hub = Hub.current diff --git a/cura/CuraActions.py b/cura/CuraActions.py index 20c44c7916..d6e5add912 100644 --- a/cura/CuraActions.py +++ b/cura/CuraActions.py @@ -40,12 +40,13 @@ class CuraActions(QObject): @pyqtSlot() def openBugReportPage(self) -> None: - event = CallFunctionEvent(self._openUrl, [QUrl("https://github.com/Ultimaker/Cura/issues")], {}) + event = CallFunctionEvent(self._openUrl, [QUrl("https://github.com/Ultimaker/Cura/issues/new/choose")], {}) cura.CuraApplication.CuraApplication.getInstance().functionEvent(event) - ## Reset camera position and direction to default @pyqtSlot() def homeCamera(self) -> None: + """Reset camera position and direction to default""" + scene = cura.CuraApplication.CuraApplication.getInstance().getController().getScene() camera = scene.getActiveCamera() if camera: @@ -54,9 +55,10 @@ class CuraActions(QObject): camera.setPerspective(True) camera.lookAt(Vector(0, 0, 0)) - ## Center all objects in the selection @pyqtSlot() def centerSelection(self) -> None: + """Center all objects in the selection""" + operation = GroupedOperation() for node in Selection.getAllSelectedObjects(): current_node = node @@ -73,18 +75,21 @@ class CuraActions(QObject): operation.addOperation(center_operation) operation.push() - ## Multiply all objects in the selection - # - # \param count The number of times to multiply the selection. @pyqtSlot(int) def multiplySelection(self, count: int) -> None: + """Multiply all objects in the selection + + :param count: The number of times to multiply the selection. + """ + min_offset = cura.CuraApplication.CuraApplication.getInstance().getBuildVolume().getEdgeDisallowedSize() + 2 # Allow for some rounding errors job = MultiplyObjectsJob(Selection.getAllSelectedObjects(), count, min_offset = max(min_offset, 8)) job.start() - ## Delete all selected objects. @pyqtSlot() def deleteSelection(self) -> None: + """Delete all selected objects.""" + if not cura.CuraApplication.CuraApplication.getInstance().getController().getToolsEnabled(): return @@ -106,11 +111,13 @@ class CuraActions(QObject): op.push() - ## Set the extruder that should be used to print the selection. - # - # \param extruder_id The ID of the extruder stack to use for the selected objects. @pyqtSlot(str) def setExtruderForSelection(self, extruder_id: str) -> None: + """Set the extruder that should be used to print the selection. + + :param extruder_id: The ID of the extruder stack to use for the selected objects. + """ + operation = GroupedOperation() nodes_to_change = [] diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index 7b17583f68..f0c69d5a61 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -4,82 +4,68 @@ import os import sys import time -from typing import cast, TYPE_CHECKING, Optional, Callable, List, Any +from typing import cast, TYPE_CHECKING, Optional, Callable, List, Any, Dict import numpy - from PyQt5.QtCore import QObject, QTimer, QUrl, pyqtSignal, pyqtProperty, QEvent, Q_ENUMS from PyQt5.QtGui import QColor, QIcon -from PyQt5.QtWidgets import QMessageBox from PyQt5.QtQml import qmlRegisterUncreatableType, qmlRegisterSingletonType, qmlRegisterType +from PyQt5.QtWidgets import QMessageBox -from UM.i18n import i18nCatalog +import UM.Util +import cura.Settings.cura_empty_instance_containers from UM.Application import Application from UM.Decorators import override from UM.FlameProfiler import pyqtSlot from UM.Logger import Logger -from UM.Message import Message -from UM.Platform import Platform -from UM.PluginError import PluginNotFoundError -from UM.Resources import Resources -from UM.Preferences import Preferences -from UM.Qt.QtApplication import QtApplication # The class we're inheriting from. -import UM.Util -from UM.View.SelectionPass import SelectionPass # For typing. - from UM.Math.AxisAlignedBox import AxisAlignedBox from UM.Math.Matrix import Matrix from UM.Math.Quaternion import Quaternion from UM.Math.Vector import Vector - from UM.Mesh.ReadMeshJob import ReadMeshJob - +from UM.Message import Message from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation from UM.Operations.GroupedOperation import GroupedOperation from UM.Operations.SetTransformOperation import SetTransformOperation - +from UM.Platform import Platform +from UM.PluginError import PluginNotFoundError +from UM.Preferences import Preferences +from UM.Qt.QtApplication import QtApplication # The class we're inheriting from. +from UM.Resources import Resources from UM.Scene.Camera import Camera from UM.Scene.GroupDecorator import GroupDecorator from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Scene.SceneNode import SceneNode +from UM.Scene.SceneNodeSettings import SceneNodeSettings from UM.Scene.Selection import Selection from UM.Scene.ToolHandle import ToolHandle - from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.InstanceContainer import InstanceContainer from UM.Settings.SettingDefinition import SettingDefinition, DefinitionPropertyType from UM.Settings.SettingFunction import SettingFunction from UM.Settings.Validator import Validator - +from UM.View.SelectionPass import SelectionPass # For typing. from UM.Workspace.WorkspaceReader import WorkspaceReader - +from UM.i18n import i18nCatalog +from cura import ApplicationMetadata from cura.API import CuraAPI - +from cura.API.Account import Account from cura.Arranging.Arrange import Arrange -from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob from cura.Arranging.ArrangeObjectsAllBuildPlatesJob import ArrangeObjectsAllBuildPlatesJob -from cura.Arranging.ShapeArray import ShapeArray - -from cura.Operations.SetParentOperation import SetParentOperation - -from cura.Scene.BlockSlicingDecorator import BlockSlicingDecorator -from cura.Scene.BuildPlateDecorator import BuildPlateDecorator -from cura.Scene.ConvexHullDecorator import ConvexHullDecorator -from cura.Scene.CuraSceneController import CuraSceneController -from cura.Scene.CuraSceneNode import CuraSceneNode - -from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator -from cura.Scene import ZOffsetDecorator +from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob +from cura.Arranging.Nest2DArrange import arrange from cura.Machines.MachineErrorChecker import MachineErrorChecker - from cura.Machines.Models.BuildPlateModel import BuildPlateModel from cura.Machines.Models.CustomQualityProfilesDropDownMenuModel import CustomQualityProfilesDropDownMenuModel from cura.Machines.Models.DiscoveredPrintersModel import DiscoveredPrintersModel +from cura.Machines.Models.DiscoveredCloudPrintersModel import DiscoveredCloudPrintersModel from cura.Machines.Models.ExtrudersModel import ExtrudersModel from cura.Machines.Models.FavoriteMaterialsModel import FavoriteMaterialsModel from cura.Machines.Models.FirstStartMachineActionsModel import FirstStartMachineActionsModel from cura.Machines.Models.GenericMaterialsModel import GenericMaterialsModel from cura.Machines.Models.GlobalStacksModel import GlobalStacksModel +from cura.Machines.Models.IntentCategoryModel import IntentCategoryModel +from cura.Machines.Models.IntentModel import IntentModel from cura.Machines.Models.MaterialBrandsModel import MaterialBrandsModel from cura.Machines.Models.MaterialManagementModel import MaterialManagementModel from cura.Machines.Models.MultiBuildPlateModel import MultiBuildPlateModel @@ -89,51 +75,47 @@ from cura.Machines.Models.QualityProfilesDropDownMenuModel import QualityProfile from cura.Machines.Models.QualitySettingsModel import QualitySettingsModel from cura.Machines.Models.SettingVisibilityPresetsModel import SettingVisibilityPresetsModel from cura.Machines.Models.UserChangesModel import UserChangesModel -from cura.Machines.Models.IntentModel import IntentModel -from cura.Machines.Models.IntentCategoryModel import IntentCategoryModel - -from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice +from cura.Operations.SetParentOperation import SetParentOperation from cura.PrinterOutput.NetworkMJPGImage import NetworkMJPGImage - -import cura.Settings.cura_empty_instance_containers +from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice +from cura.Scene import ZOffsetDecorator +from cura.Scene.BlockSlicingDecorator import BlockSlicingDecorator +from cura.Scene.BuildPlateDecorator import BuildPlateDecorator +from cura.Scene.ConvexHullDecorator import ConvexHullDecorator +from cura.Scene.CuraSceneController import CuraSceneController +from cura.Scene.CuraSceneNode import CuraSceneNode +from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator from cura.Settings.ContainerManager import ContainerManager from cura.Settings.CuraContainerRegistry import CuraContainerRegistry from cura.Settings.CuraFormulaFunctions import CuraFormulaFunctions from cura.Settings.ExtruderManager import ExtruderManager from cura.Settings.ExtruderStack import ExtruderStack +from cura.Settings.GlobalStack import GlobalStack +from cura.Settings.IntentManager import IntentManager from cura.Settings.MachineManager import MachineManager from cura.Settings.MachineNameValidator import MachineNameValidator -from cura.Settings.IntentManager import IntentManager from cura.Settings.MaterialSettingsVisibilityHandler import MaterialSettingsVisibilityHandler from cura.Settings.SettingInheritanceManager import SettingInheritanceManager from cura.Settings.SidebarCustomMenuItemsModel import SidebarCustomMenuItemsModel from cura.Settings.SimpleModeSettingsManager import SimpleModeSettingsManager - from cura.TaskManagement.OnExitCallbackManager import OnExitCallbackManager - from cura.UI import CuraSplashScreen, MachineActionManager, PrintInformation +from cura.UI.AddPrinterPagesModel import AddPrinterPagesModel from cura.UI.MachineSettingsManager import MachineSettingsManager from cura.UI.ObjectsModel import ObjectsModel -from cura.UI.TextManager import TextManager -from cura.UI.AddPrinterPagesModel import AddPrinterPagesModel from cura.UI.RecommendedMode import RecommendedMode +from cura.UI.TextManager import TextManager from cura.UI.WelcomePagesModel import WelcomePagesModel from cura.UI.WhatsNewPagesModel import WhatsNewPagesModel - +from cura.UltimakerCloud import UltimakerCloudConstants from cura.Utils.NetworkingUtil import NetworkingUtil - -from .SingleInstance import SingleInstance -from .AutoSave import AutoSave -from . import PlatformPhysics from . import BuildVolume from . import CameraAnimation from . import CuraActions +from . import PlatformPhysics from . import PrintJobPreviewImageProvider - -from cura.TaskManagement.OnExitCallbackManager import OnExitCallbackManager - -from cura import ApplicationMetadata, UltimakerCloudAuthentication -from cura.Settings.GlobalStack import GlobalStack +from .AutoSave import AutoSave +from .SingleInstance import SingleInstance if TYPE_CHECKING: from UM.Settings.EmptyInstanceContainer import EmptyInstanceContainer @@ -145,7 +127,7 @@ class CuraApplication(QtApplication): # SettingVersion represents the set of settings available in the machine/extruder definitions. # You need to make sure that this version number needs to be increased if there is any non-backwards-compatible # changes of the settings. - SettingVersion = 11 + SettingVersion = 16 Created = False @@ -222,9 +204,11 @@ class CuraApplication(QtApplication): self._quality_management_model = None self._discovered_printer_model = DiscoveredPrintersModel(self, parent = self) + self._discovered_cloud_printers_model = DiscoveredCloudPrintersModel(self, parent = self) self._first_start_machine_actions_model = None self._welcome_pages_model = WelcomePagesModel(self, parent = self) self._add_printer_pages_model = AddPrinterPagesModel(self, parent = self) + self._add_printer_pages_model_without_cancel = AddPrinterPagesModel(self, parent = self) self._whats_new_pages_model = WhatsNewPagesModel(self, parent = self) self._text_manager = TextManager(parent = self) @@ -263,6 +247,7 @@ class CuraApplication(QtApplication): # Backups self._auto_save = None # type: Optional[AutoSave] + self._enable_save = True self._container_registry_class = CuraContainerRegistry # Redefined here in order to please the typing. @@ -272,15 +257,22 @@ class CuraApplication(QtApplication): @pyqtProperty(str, constant=True) def ultimakerCloudApiRootUrl(self) -> str: - return UltimakerCloudAuthentication.CuraCloudAPIRoot + return UltimakerCloudConstants.CuraCloudAPIRoot @pyqtProperty(str, constant = True) def ultimakerCloudAccountRootUrl(self) -> str: - return UltimakerCloudAuthentication.CuraCloudAccountAPIRoot + return UltimakerCloudConstants.CuraCloudAccountAPIRoot + + @pyqtProperty(str, constant=True) + def ultimakerDigitalFactoryUrl(self) -> str: + return UltimakerCloudConstants.CuraDigitalFactoryURL - # Adds command line options to the command line parser. This should be called after the application is created and - # before the pre-start. def addCommandLineOptions(self): + """Adds command line options to the command line parser. + + This should be called after the application is created and before the pre-start. + """ + super().addCommandLineOptions() self._cli_parser.add_argument("--help", "-h", action = "store_true", @@ -322,6 +314,9 @@ class CuraApplication(QtApplication): super().initialize() + self._preferences.addPreference("cura/single_instance", False) + self._use_single_instance = self._preferences.getValue("cura/single_instance") + self.__sendCommandToSingleInstance() self._initializeSettingDefinitions() self._initializeSettingFunctions() @@ -342,8 +337,9 @@ class CuraApplication(QtApplication): Logger.log("i", "Single instance commands were sent, exiting") sys.exit(0) - # Adds expected directory names and search paths for Resources. def __addExpectedResourceDirsAndSearchPaths(self): + """Adds expected directory names and search paths for Resources.""" + # this list of dir names will be used by UM to detect an old cura directory for dir_name in ["extruders", "machine_instances", "materials", "plugins", "quality", "quality_changes", "user", "variants", "intent"]: Resources.addExpectedDirNameInData(dir_name) @@ -385,9 +381,12 @@ class CuraApplication(QtApplication): SettingDefinition.addSettingType("[int]", None, str, None) - # Adds custom property types, settings types, and extra operators (functions) that need to be registered in - # SettingDefinition and SettingFunction. def _initializeSettingFunctions(self): + """Adds custom property types, settings types, and extra operators (functions). + + Whom need to be registered in SettingDefinition and SettingFunction. + """ + self._cura_formula_functions = CuraFormulaFunctions(self) SettingFunction.registerOperator("extruderValue", self._cura_formula_functions.getValueInExtruder) @@ -397,8 +396,9 @@ class CuraApplication(QtApplication): SettingFunction.registerOperator("valueFromContainer", self._cura_formula_functions.getValueFromContainerAtIndex) SettingFunction.registerOperator("extruderValueFromContainer", self._cura_formula_functions.getValueFromContainerAtIndexInExtruder) - # Adds all resources and container related resources. def __addAllResourcesAndContainerResources(self) -> None: + """Adds all resources and container related resources.""" + Resources.addStorageType(self.ResourceTypes.QualityInstanceContainer, "quality") Resources.addStorageType(self.ResourceTypes.QualityChangesInstanceContainer, "quality_changes") Resources.addStorageType(self.ResourceTypes.VariantInstanceContainer, "variants") @@ -423,8 +423,9 @@ class CuraApplication(QtApplication): Resources.addType(self.ResourceTypes.QmlFiles, "qml") Resources.addType(self.ResourceTypes.Firmware, "firmware") - # Adds all empty containers. def __addAllEmptyContainers(self) -> None: + """Adds all empty containers.""" + # Add empty variant, material and quality containers. # Since they are empty, they should never be serialized and instead just programmatically created. # We need them to simplify the switching between materials. @@ -449,9 +450,10 @@ class CuraApplication(QtApplication): self._container_registry.addContainer(cura.Settings.cura_empty_instance_containers.empty_quality_changes_container) self.empty_quality_changes_container = cura.Settings.cura_empty_instance_containers.empty_quality_changes_container - # Initializes the version upgrade manager with by providing the paths for each resource type and the latest - # versions. def __setLatestResouceVersionsForVersionUpgrade(self): + """Initializes the version upgrade manager with by providing the paths for each resource type and the latest + versions. """ + self._version_upgrade_manager.setCurrentVersions( { ("quality", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityInstanceContainer, "application/x-uranium-instancecontainer"), @@ -466,12 +468,16 @@ class CuraApplication(QtApplication): } ) - # Runs preparations that needs to be done before the starting process. def startSplashWindowPhase(self) -> None: + """Runs preparations that needs to be done before the starting process.""" + super().startSplashWindowPhase() if not self.getIsHeadLess(): - self.setWindowIcon(QIcon(Resources.getPath(Resources.Images, "cura-icon.png"))) + try: + self.setWindowIcon(QIcon(Resources.getPath(Resources.Images, "cura-icon.png"))) + except FileNotFoundError: + Logger.log("w", "Unable to find the window icon.") self.setRequiredPlugins([ # Misc.: @@ -523,7 +529,7 @@ class CuraApplication(QtApplication): # Set the setting version for Preferences preferences = self.getPreferences() preferences.addPreference("metadata/setting_version", 0) - preferences.setValue("metadata/setting_version", self.SettingVersion) #Don't make it equal to the default so that the setting version always gets written to the file. + preferences.setValue("metadata/setting_version", self.SettingVersion) # Don't make it equal to the default so that the setting version always gets written to the file. preferences.addPreference("cura/active_mode", "simple") @@ -627,12 +633,13 @@ class CuraApplication(QtApplication): def callConfirmExitDialogCallback(self, yes_or_no: bool) -> None: self._confirm_exit_dialog_callback(yes_or_no) - ## Signal to connect preferences action in QML showPreferencesWindow = pyqtSignal() + """Signal to connect preferences action in QML""" - ## Show the preferences window @pyqtSlot() def showPreferences(self) -> None: + """Show the preferences window""" + self.showPreferencesWindow.emit() # This is called by drag-and-dropping curapackage files. @@ -646,14 +653,13 @@ class CuraApplication(QtApplication): return self._global_container_stack @override(Application) - def setGlobalContainerStack(self, stack: "GlobalStack") -> None: + def setGlobalContainerStack(self, stack: Optional["GlobalStack"]) -> None: self._setLoadingHint(self._i18n_catalog.i18nc("@info:progress", "Initializing Active Machine...")) super().setGlobalContainerStack(stack) - ## A reusable dialogbox - # showMessageBox = pyqtSignal(str,str, str, str, int, int, arguments = ["title", "text", "informativeText", "detailedText","buttons", "icon"]) + """A reusable dialogbox""" def messageBox(self, title, text, informativeText = "", @@ -706,15 +712,20 @@ class CuraApplication(QtApplication): self._message_box_callback = None self._message_box_callback_arguments = [] + def enableSave(self, enable: bool): + self._enable_save = enable + # Cura has multiple locations where instance containers need to be saved, so we need to handle this differently. def saveSettings(self) -> None: - if not self.started: + if not self.started or not self._enable_save: # Do not do saving during application start or when data should not be saved on quit. return ContainerRegistry.getInstance().saveDirtyContainers() self.savePreferences() def saveStack(self, stack): + if not self._enable_save: + return ContainerRegistry.getInstance().saveContainer(stack) @pyqtSlot(str, result = QUrl) @@ -726,9 +737,12 @@ class CuraApplication(QtApplication): def setDefaultPath(self, key, default_path): self.getPreferences().setValue("local_file/%s" % key, QUrl(default_path).toLocalFile()) - ## Handle loading of all plugin types (and the backend explicitly) - # \sa PluginRegistry def _loadPlugins(self) -> None: + """Handle loading of all plugin types (and the backend explicitly) + + :py:class:`Uranium.UM.PluginRegistry` + """ + self._plugin_registry.setCheckIfTrusted(ApplicationMetadata.IsEnterpriseVersion) self._plugin_registry.addType("profile_reader", self._addProfileReader) @@ -742,8 +756,7 @@ class CuraApplication(QtApplication): self._plugin_registry.addPluginLocation(os.path.join(QtApplication.getInstallPrefix(), "lib" + suffix, "cura")) if not hasattr(sys, "frozen"): self._plugin_registry.addPluginLocation(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "plugins")) - self._plugin_registry.loadPlugin("ConsoleLogger") - self._plugin_registry.loadPlugin("CuraEngineBackend") + self._plugin_registry.preloaded_plugins.append("ConsoleLogger") self._plugin_registry.loadPlugins() @@ -752,9 +765,12 @@ class CuraApplication(QtApplication): self._plugins_loaded = True - ## Set a short, user-friendly hint about current loading status. - # The way this message is displayed depends on application state def _setLoadingHint(self, hint: str): + """Set a short, user-friendly hint about current loading status. + + The way this message is displayed depends on application state + """ + if self.started: Logger.info(hint) else: @@ -786,6 +802,8 @@ class CuraApplication(QtApplication): self._setLoadingHint(self._i18n_catalog.i18nc("@info:progress", "Initializing build volume...")) root = self.getController().getScene().getRoot() self._volume = BuildVolume.BuildVolume(self, root) + + # Ensure that the old style arranger still works. Arrange.build_volume = self._volume # initialize info objects @@ -801,6 +819,7 @@ class CuraApplication(QtApplication): self._output_device_manager.start() self._welcome_pages_model.initialize() self._add_printer_pages_model.initialize() + self._add_printer_pages_model_without_cancel.initialize(cancellable = False) self._whats_new_pages_model.initialize() # Detect in which mode to run and execute that mode @@ -838,13 +857,16 @@ class CuraApplication(QtApplication): self.callLater(self._openFile, file_name) initializationFinished = pyqtSignal() + showAddPrintersUncancellableDialog = pyqtSignal() # Used to show the add printers dialog with a greyed background - ## Run Cura without GUI elements and interaction (server mode). def runWithoutGUI(self): + """Run Cura without GUI elements and interaction (server mode).""" + self.closeSplash() - ## Run Cura with GUI (desktop mode). def runWithGUI(self): + """Run Cura with GUI (desktop mode).""" + self._setLoadingHint(self._i18n_catalog.i18nc("@info:progress", "Setting up scene...")) controller = self.getController() @@ -873,8 +895,9 @@ class CuraApplication(QtApplication): # Initialize camera tool camera_tool = controller.getTool("CameraTool") - camera_tool.setOrigin(Vector(0, 100, 0)) - camera_tool.setZoomRange(0.1, 2000) + if camera_tool: + camera_tool.setOrigin(Vector(0, 100, 0)) + camera_tool.setZoomRange(0.1, 2000) # Initialize camera animations self._camera_animation = CameraAnimation.CameraAnimation() @@ -901,6 +924,10 @@ class CuraApplication(QtApplication): def getDiscoveredPrintersModel(self, *args) -> "DiscoveredPrintersModel": return self._discovered_printer_model + @pyqtSlot(result=QObject) + def getDiscoveredCloudPrintersModel(self, *args) -> "DiscoveredCloudPrintersModel": + return self._discovered_cloud_printers_model + @pyqtSlot(result = QObject) def getFirstStartMachineActionsModel(self, *args) -> "FirstStartMachineActionsModel": if self._first_start_machine_actions_model is None: @@ -922,6 +949,10 @@ class CuraApplication(QtApplication): return self._add_printer_pages_model @pyqtSlot(result = QObject) + def getAddPrinterPagesModelWithoutCancel(self, *args) -> "AddPrinterPagesModel": + return self._add_printer_pages_model_without_cancel + + @pyqtSlot(result = QObject) def getWhatsNewPagesModel(self, *args) -> "WhatsNewPagesModel": return self._whats_new_pages_model @@ -994,10 +1025,13 @@ class CuraApplication(QtApplication): self._setting_inheritance_manager = SettingInheritanceManager.createSettingInheritanceManager() return self._setting_inheritance_manager - ## Get the machine action manager - # We ignore any *args given to this, as we also register the machine manager as qml singleton. - # It wants to give this function an engine and script engine, but we don't care about that. def getMachineActionManager(self, *args: Any) -> MachineActionManager.MachineActionManager: + """Get the machine action manager + + We ignore any *args given to this, as we also register the machine manager as qml singleton. + It wants to give this function an engine and script engine, but we don't care about that. + """ + return cast(MachineActionManager.MachineActionManager, self._machine_action_manager) @pyqtSlot(result = QObject) @@ -1017,8 +1051,9 @@ class CuraApplication(QtApplication): self._simple_mode_settings_manager = SimpleModeSettingsManager() return self._simple_mode_settings_manager - ## Handle Qt events def event(self, event): + """Handle Qt events""" + if event.type() == QEvent.FileOpen: if self._plugins_loaded: self._openFile(event.file()) @@ -1030,8 +1065,9 @@ class CuraApplication(QtApplication): def getAutoSave(self) -> Optional[AutoSave]: return self._auto_save - ## Get print information (duration / material used) def getPrintInformation(self): + """Get print information (duration / material used)""" + return self._print_information def getQualityProfilesDropDownMenuModel(self, *args, **kwargs): @@ -1047,10 +1083,12 @@ class CuraApplication(QtApplication): def getCuraAPI(self, *args, **kwargs) -> "CuraAPI": return self._cura_API - ## Registers objects for the QML engine to use. - # - # \param engine The QML engine. def registerObjects(self, engine): + """Registers objects for the QML engine to use. + + :param engine: The QML engine. + """ + super().registerObjects(engine) # global contexts @@ -1099,6 +1137,7 @@ class CuraApplication(QtApplication): self.processEvents() qmlRegisterType(DiscoveredPrintersModel, "Cura", 1, 0, "DiscoveredPrintersModel") + qmlRegisterType(DiscoveredCloudPrintersModel, "Cura", 1, 7, "DiscoveredCloudPrintersModel") qmlRegisterSingletonType(QualityProfilesDropDownMenuModel, "Cura", 1, 0, "QualityProfilesDropDownMenuModel", self.getQualityProfilesDropDownMenuModel) qmlRegisterSingletonType(CustomQualityProfilesDropDownMenuModel, "Cura", 1, 0, @@ -1121,6 +1160,7 @@ class CuraApplication(QtApplication): from cura.API import CuraAPI qmlRegisterSingletonType(CuraAPI, "Cura", 1, 1, "API", self.getCuraAPI) + qmlRegisterUncreatableType(Account, "Cura", 1, 0, "AccountSyncState", "Could not create AccountSyncState") # As of Qt5.7, it is necessary to get rid of any ".." in the path for the singleton to work. actions_url = QUrl.fromLocalFile(os.path.abspath(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles, "Actions.qml"))) @@ -1184,8 +1224,9 @@ class CuraApplication(QtApplication): if node is not None and (node.getMeshData() is not None or node.callDecoration("getLayerData")): self._update_platform_activity_timer.start() - ## Update scene bounding box for current build plate def updatePlatformActivity(self, node = None): + """Update scene bounding box for current build plate""" + count = 0 scene_bounding_box = None is_block_slicing_node = False @@ -1229,9 +1270,10 @@ class CuraApplication(QtApplication): self._platform_activity = True if count > 0 else False self.activityChanged.emit() - ## Select all nodes containing mesh data in the scene. @pyqtSlot() def selectAll(self): + """Select all nodes containing mesh data in the scene.""" + if not self.getController().getToolsEnabled(): return @@ -1250,9 +1292,10 @@ class CuraApplication(QtApplication): Selection.add(node) - ## Reset all translation on nodes with mesh data. @pyqtSlot() def resetAllTranslation(self): + """Reset all translation on nodes with mesh data.""" + Logger.log("i", "Resetting all scene translations") nodes = [] for node in DepthFirstIterator(self.getController().getScene().getRoot()): @@ -1278,9 +1321,10 @@ class CuraApplication(QtApplication): op.addOperation(SetTransformOperation(node, Vector(0, center_y, 0))) op.push() - ## Reset all transformations on nodes with mesh data. @pyqtSlot() def resetAll(self): + """Reset all transformations on nodes with mesh data.""" + Logger.log("i", "Resetting all scene transformations") nodes = [] for node in DepthFirstIterator(self.getController().getScene().getRoot()): @@ -1306,9 +1350,10 @@ class CuraApplication(QtApplication): op.addOperation(SetTransformOperation(node, Vector(0, center_y, 0), Quaternion(), Vector(1, 1, 1))) op.push() - ## Arrange all objects. @pyqtSlot() def arrangeObjectsToAllBuildPlates(self) -> None: + """Arrange all objects.""" + nodes_to_arrange = [] for node in DepthFirstIterator(self.getController().getScene().getRoot()): if not isinstance(node, SceneNode): @@ -1337,6 +1382,7 @@ class CuraApplication(QtApplication): def arrangeAll(self) -> None: nodes_to_arrange = [] active_build_plate = self.getMultiBuildPlateModel().activeBuildPlate + locked_nodes = [] for node in DepthFirstIterator(self.getController().getScene().getRoot()): if not isinstance(node, SceneNode): continue @@ -1358,20 +1404,28 @@ class CuraApplication(QtApplication): # Skip nodes that are too big bounding_box = node.getBoundingBox() if bounding_box is None or bounding_box.width < self._volume.getBoundingBox().width or bounding_box.depth < self._volume.getBoundingBox().depth: - nodes_to_arrange.append(node) - self.arrange(nodes_to_arrange, fixed_nodes = []) + # Arrange only the unlocked nodes and keep the locked ones in place + if UM.Util.parseBool(node.getSetting(SceneNodeSettings.LockPosition)): + locked_nodes.append(node) + else: + nodes_to_arrange.append(node) + self.arrange(nodes_to_arrange, locked_nodes) - ## Arrange a set of nodes given a set of fixed nodes - # \param nodes nodes that we have to place - # \param fixed_nodes nodes that are placed in the arranger before finding spots for nodes def arrange(self, nodes: List[SceneNode], fixed_nodes: List[SceneNode]) -> None: + """Arrange a set of nodes given a set of fixed nodes + + :param nodes: nodes that we have to place + :param fixed_nodes: nodes that are placed in the arranger before finding spots for nodes + """ + min_offset = self.getBuildVolume().getEdgeDisallowedSize() + 2 # Allow for some rounding errors job = ArrangeObjectsJob(nodes, fixed_nodes, min_offset = max(min_offset, 8)) job.start() - ## Reload all mesh data on the screen from file. @pyqtSlot() def reloadAll(self) -> None: + """Reload all mesh data on the screen from file.""" + Logger.log("i", "Reloading all loaded mesh data.") nodes = [] has_merged_nodes = False @@ -1397,22 +1451,29 @@ class CuraApplication(QtApplication): if not nodes: return + objects_in_filename = {} # type: Dict[str, List[CuraSceneNode]] for node in nodes: mesh_data = node.getMeshData() - if mesh_data: file_name = mesh_data.getFileName() if file_name: - job = ReadMeshJob(file_name) - job._node = node # type: ignore - job.finished.connect(self._reloadMeshFinished) - if has_merged_nodes: - job.finished.connect(self.updateOriginOfMergedMeshes) - - job.start() + if file_name not in objects_in_filename: + objects_in_filename[file_name] = [] + if file_name in objects_in_filename: + objects_in_filename[file_name].append(node) else: Logger.log("w", "Unable to reload data because we don't have a filename.") + for file_name, nodes in objects_in_filename.items(): + for node in nodes: + job = ReadMeshJob(file_name) + job._node = node # type: ignore + job.finished.connect(self._reloadMeshFinished) + if has_merged_nodes: + job.finished.connect(self.updateOriginOfMergedMeshes) + + job.start() + @pyqtSlot("QStringList") def setExpandedCategories(self, categories: List[str]) -> None: categories = list(set(categories)) @@ -1461,21 +1522,19 @@ class CuraApplication(QtApplication): # Move each node to the same position. for mesh, node in zip(meshes, group_node.getChildren()): - transformation = node.getLocalTransformation() - transformation.setTranslation(zero_translation) - transformed_mesh = mesh.getTransformed(transformation) - + node.setTransformation(Matrix()) # Align the object around its zero position # and also apply the offset to center it inside the group. - node.setPosition(-transformed_mesh.getZeroPosition() - offset) + node.setPosition(-mesh.getZeroPosition() - offset) # Use the previously found center of the group bounding box as the new location of the group group_node.setPosition(group_node.getBoundingBox().center) group_node.setName("MergedMesh") # add a specific name to distinguish this node - ## Updates origin position of all merged meshes def updateOriginOfMergedMeshes(self, _): + """Updates origin position of all merged meshes""" + group_nodes = [] for node in DepthFirstIterator(self.getController().getScene().getRoot()): if isinstance(node, CuraSceneNode) and node.getName() == "MergedMesh": @@ -1587,13 +1646,31 @@ class CuraApplication(QtApplication): fileLoaded = pyqtSignal(str) fileCompleted = pyqtSignal(str) - def _reloadMeshFinished(self, job): - # TODO; This needs to be fixed properly. We now make the assumption that we only load a single mesh! - job_result = job.getResult() + def _reloadMeshFinished(self, job) -> None: + """ + Function called whenever a ReadMeshJob finishes in the background. It reloads a specific node object in the + scene from its source file. The function gets all the nodes that exist in the file through the job result, and + then finds the scene node that it wants to refresh by its object id. Each job refreshes only one node. + + :param job: The :py:class:`Uranium.UM.ReadMeshJob.ReadMeshJob` running in the background that reads all the + meshes in a file + """ + + job_result = job.getResult() # nodes that exist inside the file read by this job if len(job_result) == 0: Logger.log("e", "Reloading the mesh failed.") return - mesh_data = job_result[0].getMeshData() + object_found = False + mesh_data = None + # Find the node to be refreshed based on its id + for job_result_node in job_result: + if job_result_node.getId() == job._node.getId(): + mesh_data = job_result_node.getMeshData() + object_found = True + break + if not object_found: + Logger.warning("The object with id {} no longer exists! Keeping the old version in the scene.".format(job_result_node.getId())) + return if not mesh_data: Logger.log("w", "Could not find a mesh in reloaded node.") return @@ -1624,12 +1701,15 @@ class CuraApplication(QtApplication): def additionalComponents(self): return self._additional_components - ## Add a component to a list of components to be reparented to another area in the GUI. - # The actual reparenting is done by the area itself. - # \param area_id \type{str} Identifying name of the area to which the component should be reparented - # \param component \type{QQuickComponent} The component that should be reparented @pyqtSlot(str, "QVariant") - def addAdditionalComponent(self, area_id, component): + def addAdditionalComponent(self, area_id: str, component): + """Add a component to a list of components to be reparented to another area in the GUI. + + The actual reparenting is done by the area itself. + :param area_id: dentifying name of the area to which the component should be reparented + :param (QQuickComponent) component: The component that should be reparented + """ + if area_id not in self._additional_components: self._additional_components[area_id] = [] self._additional_components[area_id].append(component) @@ -1644,10 +1724,13 @@ class CuraApplication(QtApplication): @pyqtSlot(QUrl, str) @pyqtSlot(QUrl) - ## Open a local file - # \param project_mode How to handle project files. Either None(default): Follow user preference, "open_as_model" or - # "open_as_project". This parameter is only considered if the file is a project file. def readLocalFile(self, file: QUrl, project_mode: Optional[str] = None): + """Open a local file + + :param project_mode: How to handle project files. Either None(default): Follow user preference, "open_as_model" + or "open_as_project". This parameter is only considered if the file is a project file. + """ + Logger.log("i", "Attempting to read file %s", file.toString()) if not file.isValid(): return @@ -1716,8 +1799,14 @@ class CuraApplication(QtApplication): if not global_container_stack: Logger.log("w", "Can't load meshes before a printer is added.") return + if not self._volume: + Logger.log("w", "Can't load meshes before the build volume is initialized") + return nodes = job.getResult() + if nodes is None: + Logger.error("Read mesh job returned None. Mesh loading must have failed.") + return file_name = job.getFileName() file_name_lower = file_name.lower() file_extension = file_name_lower.split(".")[-1] @@ -1731,17 +1820,21 @@ class CuraApplication(QtApplication): for node_ in DepthFirstIterator(root): if node_.callDecoration("isSliceable") and node_.callDecoration("getBuildPlateNumber") == target_build_plate: fixed_nodes.append(node_) - machine_width = global_container_stack.getProperty("machine_width", "value") - machine_depth = global_container_stack.getProperty("machine_depth", "value") - arranger = Arrange.create(x = machine_width, y = machine_depth, fixed_nodes = fixed_nodes) - min_offset = 8 + default_extruder_position = self.getMachineManager().defaultExtruderPosition default_extruder_id = self._global_container_stack.extruderList[int(default_extruder_position)].getId() select_models_on_load = self.getPreferences().getValue("cura/select_models_on_load") - for original_node in nodes: + nodes_to_arrange = [] # type: List[CuraSceneNode] + + fixed_nodes = [] + for node_ in DepthFirstIterator(self.getController().getScene().getRoot()): + # Only count sliceable objects + if node_.callDecoration("isSliceable"): + fixed_nodes.append(node_) + for original_node in nodes: # Create a CuraSceneNode just if the original node is not that type if isinstance(original_node, CuraSceneNode): node = original_node @@ -1749,8 +1842,8 @@ class CuraApplication(QtApplication): node = CuraSceneNode() node.setMeshData(original_node.getMeshData()) - #Setting meshdata does not apply scaling. - if(original_node.getScale() != Vector(1.0, 1.0, 1.0)): + # Setting meshdata does not apply scaling. + if original_node.getScale() != Vector(1.0, 1.0, 1.0): node.scale(original_node.getScale()) node.setSelectable(True) @@ -1781,19 +1874,15 @@ class CuraApplication(QtApplication): if file_extension != "3mf": if node.callDecoration("isSliceable"): - # Only check position if it's not already blatantly obvious that it won't fit. - if node.getBoundingBox() is None or self._volume.getBoundingBox() is None or node.getBoundingBox().width < self._volume.getBoundingBox().width or node.getBoundingBox().depth < self._volume.getBoundingBox().depth: - # Find node location - offset_shape_arr, hull_shape_arr = ShapeArray.fromNode(node, min_offset = min_offset) + # Ensure that the bottom of the bounding box is on the build plate + if node.getBoundingBox(): + center_y = node.getWorldPosition().y - node.getBoundingBox().bottom + else: + center_y = 0 - # If a model is to small then it will not contain any points - if offset_shape_arr is None and hull_shape_arr is None: - Message(self._i18n_catalog.i18nc("@info:status", "The selected model was too small to load."), - title=self._i18n_catalog.i18nc("@info:title", "Warning")).show() - return + node.translate(Vector(0, center_y, 0)) - # Step is for skipping tests to make it a lot faster. it also makes the outcome somewhat rougher - arranger.findNodePlacement(node, offset_shape_arr, hull_shape_arr, step = 10) + nodes_to_arrange.append(node) # This node is deep copied from some other node which already has a BuildPlateDecorator, but the deepcopy # of BuildPlateDecorator produces one that's associated with build plate -1. So, here we need to check if @@ -1812,7 +1901,10 @@ class CuraApplication(QtApplication): if select_models_on_load: Selection.add(node) - + try: + arrange(nodes_to_arrange, self.getBuildVolume(), fixed_nodes) + except: + Logger.logException("e", "Failed to arrange the models") self.fileCompleted.emit(file_name) def addNonSliceableExtension(self, extension): @@ -1820,9 +1912,8 @@ class CuraApplication(QtApplication): @pyqtSlot(str, result=bool) def checkIsValidProjectFile(self, file_url): - """ - Checks if the given file URL is a valid project file. - """ + """Checks if the given file URL is a valid project file. """ + file_path = QUrl(file_url).toLocalFile() workspace_reader = self.getWorkspaceFileHandler().getReaderForFile(file_path) if workspace_reader is None: @@ -1840,7 +1931,6 @@ class CuraApplication(QtApplication): return selection_pass = cast(SelectionPass, self.getRenderer().getRenderPass("selection")) if not selection_pass: # If you right-click before the rendering has been initialised there might not be a selection pass yet. - print("--------------ding! Got the crash.") return node = self.getController().getScene().findObject(selection_pass.getIdAtPosition(x, y)) if not node: diff --git a/cura/CuraPackageManager.py b/cura/CuraPackageManager.py index a0d3a8d44a..26d6591099 100644 --- a/cura/CuraPackageManager.py +++ b/cura/CuraPackageManager.py @@ -24,11 +24,15 @@ class CuraPackageManager(PackageManager): super().initialize() - ## Returns a list of where the package is used - # empty if it is never used. - # It loops through all the package contents and see if some of the ids are used. - # The list consists of 3-tuples: (global_stack, extruder_nr, container_id) def getMachinesUsingPackage(self, package_id: str) -> Tuple[List[Tuple[GlobalStack, str, str]], List[Tuple[GlobalStack, str, str]]]: + """Returns a list of where the package is used + + It loops through all the package contents and see if some of the ids are used. + + :param package_id: package id to search for + :return: empty if it is never used, otherwise a list consisting of 3-tuples + """ + ids = self.getPackageContainerIds(package_id) container_stacks = self._application.getContainerRegistry().findContainerStacks() global_stacks = [container_stack for container_stack in container_stacks if isinstance(container_stack, GlobalStack)] @@ -36,10 +40,10 @@ class CuraPackageManager(PackageManager): machine_with_qualities = [] for container_id in ids: for global_stack in global_stacks: - for extruder_nr, extruder_stack in global_stack.extruders.items(): + for extruder_nr, extruder_stack in enumerate(global_stack.extruderList): if container_id in (extruder_stack.material.getId(), extruder_stack.material.getMetaData().get("base_file")): - machine_with_materials.append((global_stack, extruder_nr, container_id)) + machine_with_materials.append((global_stack, str(extruder_nr), container_id)) if container_id == extruder_stack.quality.getId(): - machine_with_qualities.append((global_stack, extruder_nr, container_id)) + machine_with_qualities.append((global_stack, str(extruder_nr), container_id)) return machine_with_materials, machine_with_qualities diff --git a/cura/CuraVersion.py.in b/cura/CuraVersion.py.in index 32a67b8baa..ce2264f5fc 100644 --- a/cura/CuraVersion.py.in +++ b/cura/CuraVersion.py.in @@ -9,4 +9,5 @@ CuraDebugMode = True if "@_cura_debugmode@" == "ON" else False CuraCloudAPIRoot = "@CURA_CLOUD_API_ROOT@" CuraCloudAPIVersion = "@CURA_CLOUD_API_VERSION@" CuraCloudAccountAPIRoot = "@CURA_CLOUD_ACCOUNT_API_ROOT@" -CuraMarketplaceRoot = "@CURA_MARKETPLACE_ROOT@"
\ No newline at end of file +CuraMarketplaceRoot = "@CURA_MARKETPLACE_ROOT@" +CuraDigitalFactoryURL = "@CURA_DIGITAL_FACTORY_URL@" diff --git a/cura/Layer.py b/cura/Layer.py index 933d4436c9..af42488e2a 100644 --- a/cura/Layer.py +++ b/cura/Layer.py @@ -76,7 +76,7 @@ class Layer: def createMeshOrJumps(self, make_mesh: bool) -> MeshData: builder = MeshBuilder() - + line_count = 0 if make_mesh: for polygon in self._polygons: @@ -87,7 +87,7 @@ class Layer: # Reserve the necessary space for the data upfront builder.reserveFaceAndVertexCount(2 * line_count, 4 * line_count) - + for polygon in self._polygons: # Filter out the types of lines we are not interested in depending on whether we are drawing the mesh or the jumps. index_mask = numpy.logical_not(polygon.jumpMask) if make_mesh else polygon.jumpMask @@ -96,7 +96,7 @@ class Layer: points = numpy.concatenate((polygon.data[:-1], polygon.data[1:]), 1)[index_mask.ravel()] # Line types of the points we want to draw line_types = polygon.types[index_mask] - + # Shift the z-axis according to previous implementation. if make_mesh: points[polygon.isInfillOrSkinType(line_types), 1::3] -= 0.01 @@ -118,5 +118,5 @@ class Layer: f_colors = numpy.repeat(polygon.mapLineTypeToColor(line_types), 4, 0) builder.addFacesWithColor(f_points, f_indices, f_colors) - + return builder.build()
\ No newline at end of file diff --git a/cura/LayerData.py b/cura/LayerData.py index 72824591ab..e58fda597a 100644 --- a/cura/LayerData.py +++ b/cura/LayerData.py @@ -3,9 +3,12 @@ from UM.Mesh.MeshData import MeshData -## Class to holds the layer mesh and information about the layers. -# Immutable, use LayerDataBuilder to create one of these. class LayerData(MeshData): + """Class to holds the layer mesh and information about the layers. + + Immutable, use :py:class:`cura.LayerDataBuilder.LayerDataBuilder` to create one of these. + """ + def __init__(self, vertices = None, normals = None, indices = None, colors = None, uvs = None, file_name = None, center_position = None, layers=None, element_counts=None, attributes=None): super().__init__(vertices=vertices, normals=normals, indices=indices, colors=colors, uvs=uvs, diff --git a/cura/LayerDataBuilder.py b/cura/LayerDataBuilder.py index e8d1b8c59f..d8801c9e7b 100755 --- a/cura/LayerDataBuilder.py +++ b/cura/LayerDataBuilder.py @@ -10,8 +10,9 @@ import numpy from typing import Dict, Optional -## Builder class for constructing a LayerData object class LayerDataBuilder(MeshBuilder): + """Builder class for constructing a :py:class:`cura.LayerData.LayerData` object""" + def __init__(self) -> None: super().__init__() self._layers = {} # type: Dict[int, Layer] @@ -42,11 +43,13 @@ class LayerDataBuilder(MeshBuilder): self._layers[layer].setThickness(thickness) - ## Return the layer data as LayerData. - # - # \param material_color_map: [r, g, b, a] for each extruder row. - # \param line_type_brightness: compatibility layer view uses line type brightness of 0.5 def build(self, material_color_map, line_type_brightness = 1.0): + """Return the layer data as :py:class:`cura.LayerData.LayerData`. + + :param material_color_map: [r, g, b, a] for each extruder row. + :param line_type_brightness: compatibility layer view uses line type brightness of 0.5 + """ + vertex_count = 0 index_count = 0 for layer, data in self._layers.items(): diff --git a/cura/LayerDataDecorator.py b/cura/LayerDataDecorator.py index 36466cac72..66924e8d2c 100644 --- a/cura/LayerDataDecorator.py +++ b/cura/LayerDataDecorator.py @@ -7,8 +7,9 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator from cura.LayerData import LayerData -## Simple decorator to indicate a scene node holds layer data. class LayerDataDecorator(SceneNodeDecorator): + """Simple decorator to indicate a scene node holds layer data.""" + def __init__(self) -> None: super().__init__() self._layer_data = None # type: Optional[LayerData] diff --git a/cura/LayerPolygon.py b/cura/LayerPolygon.py index 70d818f1ca..6e518e984a 100644 --- a/cura/LayerPolygon.py +++ b/cura/LayerPolygon.py @@ -26,14 +26,17 @@ class LayerPolygon: __jump_map = numpy.logical_or(numpy.logical_or(numpy.arange(__number_of_types) == NoneType, numpy.arange(__number_of_types) == MoveCombingType), numpy.arange(__number_of_types) == MoveRetractionType) - ## LayerPolygon, used in ProcessSlicedLayersJob - # \param extruder The position of the extruder - # \param line_types array with line_types - # \param data new_points - # \param line_widths array with line widths - # \param line_thicknesses: array with type as index and thickness as value - # \param line_feedrates array with line feedrates def __init__(self, extruder: int, line_types: numpy.ndarray, data: numpy.ndarray, line_widths: numpy.ndarray, line_thicknesses: numpy.ndarray, line_feedrates: numpy.ndarray) -> None: + """LayerPolygon, used in ProcessSlicedLayersJob + + :param extruder: The position of the extruder + :param line_types: array with line_types + :param data: new_points + :param line_widths: array with line widths + :param line_thicknesses: array with type as index and thickness as value + :param line_feedrates: array with line feedrates + """ + self._extruder = extruder self._types = line_types for i in range(len(self._types)): @@ -59,21 +62,21 @@ class LayerPolygon: # re-used and can save alot of memory usage. self._color_map = LayerPolygon.getColorMap() self._colors = self._color_map[self._types] # type: numpy.ndarray - + # When type is used as index returns true if type == LayerPolygon.InfillType or type == LayerPolygon.SkinType or type == LayerPolygon.SupportInfillType # Should be generated in better way, not hardcoded. self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = numpy.bool) - + self._build_cache_line_mesh_mask = None # type: Optional[numpy.ndarray] self._build_cache_needed_points = None # type: Optional[numpy.ndarray] - + def buildCache(self) -> None: # For the line mesh we do not draw Infill or Jumps. Therefore those lines are filtered out. self._build_cache_line_mesh_mask = numpy.ones(self._jump_mask.shape, dtype = bool) mesh_line_count = numpy.sum(self._build_cache_line_mesh_mask) self._index_begin = 0 self._index_end = mesh_line_count - + self._build_cache_needed_points = numpy.ones((len(self._types), 2), dtype = numpy.bool) # Only if the type of line segment changes do we need to add an extra vertex to change colors self._build_cache_needed_points[1:, 0][:, numpy.newaxis] = self._types[1:] != self._types[:-1] @@ -83,19 +86,22 @@ class LayerPolygon: self._vertex_begin = 0 self._vertex_end = numpy.sum( self._build_cache_needed_points ) - ## Set all the arrays provided by the function caller, representing the LayerPolygon - # The arrays are either by vertex or by indices. - # - # \param vertex_offset : determines where to start and end filling the arrays - # \param index_offset : determines where to start and end filling the arrays - # \param vertices : vertex numpy array to be filled - # \param colors : vertex numpy array to be filled - # \param line_dimensions : vertex numpy array to be filled - # \param feedrates : vertex numpy array to be filled - # \param extruders : vertex numpy array to be filled - # \param line_types : vertex numpy array to be filled - # \param indices : index numpy array to be filled def build(self, vertex_offset: int, index_offset: int, vertices: numpy.ndarray, colors: numpy.ndarray, line_dimensions: numpy.ndarray, feedrates: numpy.ndarray, extruders: numpy.ndarray, line_types: numpy.ndarray, indices: numpy.ndarray) -> None: + """Set all the arrays provided by the function caller, representing the LayerPolygon + + The arrays are either by vertex or by indices. + + :param vertex_offset: determines where to start and end filling the arrays + :param index_offset: determines where to start and end filling the arrays + :param vertices: vertex numpy array to be filled + :param colors: vertex numpy array to be filled + :param line_dimensions: vertex numpy array to be filled + :param feedrates: vertex numpy array to be filled + :param extruders: vertex numpy array to be filled + :param line_types: vertex numpy array to be filled + :param indices: index numpy array to be filled + """ + if self._build_cache_line_mesh_mask is None or self._build_cache_needed_points is None: self.buildCache() @@ -105,16 +111,16 @@ class LayerPolygon: line_mesh_mask = self._build_cache_line_mesh_mask needed_points_list = self._build_cache_needed_points - + # Index to the points we need to represent the line mesh. This is constructed by generating simple # start and end points for each line. For line segment n these are points n and n+1. Row n reads [n n+1] # Then then the indices for the points we don't need are thrown away based on the pre-calculated list. index_list = ( numpy.arange(len(self._types)).reshape((-1, 1)) + numpy.array([[0, 1]]) ).reshape((-1, 1))[needed_points_list.reshape((-1, 1))] - + # The relative values of begin and end indices have already been set in buildCache, so we only need to offset them to the parents offset. self._vertex_begin += vertex_offset self._vertex_end += vertex_offset - + # Points are picked based on the index list to get the vertices needed. vertices[self._vertex_begin:self._vertex_end, :] = self._data[index_list, :] @@ -136,14 +142,14 @@ class LayerPolygon: # The relative values of begin and end indices have already been set in buildCache, so we only need to offset them to the parents offset. self._index_begin += index_offset self._index_end += index_offset - + indices[self._index_begin:self._index_end, :] = numpy.arange(self._index_end-self._index_begin, dtype = numpy.int32).reshape((-1, 1)) # When the line type changes the index needs to be increased by 2. indices[self._index_begin:self._index_end, :] += numpy.cumsum(needed_points_list[line_mesh_mask.ravel(), 0], dtype = numpy.int32).reshape((-1, 1)) # Each line segment goes from it's starting point p to p+1, offset by the vertex index. # The -1 is to compensate for the neccecarily True value of needed_points_list[0,0] which causes an unwanted +1 in cumsum above. 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_needed_points = None @@ -189,7 +195,7 @@ class LayerPolygon: @property def lineFeedrates(self): return self._line_feedrates - + @property def jumpMask(self): return self._jump_mask @@ -202,8 +208,12 @@ class LayerPolygon: def jumpCount(self): return self._jump_count - # Calculate normals for the entire polygon using numpy. def getNormals(self) -> numpy.ndarray: + """Calculate normals for the entire polygon using numpy. + + :return: normals for the entire polygon + """ + normals = numpy.copy(self._data) normals[:, 1] = 0.0 # We are only interested in 2D normals @@ -229,9 +239,10 @@ class LayerPolygon: __color_map = None # type: numpy.ndarray - ## Gets the instance of the VersionUpgradeManager, or creates one. @classmethod def getColorMap(cls) -> numpy.ndarray: + """Gets the instance of the VersionUpgradeManager, or creates one.""" + if cls.__color_map is None: theme = cast(Theme, QtApplication.getInstance().getTheme()) cls.__color_map = numpy.array([ diff --git a/cura/MachineAction.py b/cura/MachineAction.py index 0f05401c89..74b742ef4d 100644 --- a/cura/MachineAction.py +++ b/cura/MachineAction.py @@ -11,16 +11,22 @@ from UM.PluginObject import PluginObject from UM.PluginRegistry import PluginRegistry -## Machine actions are actions that are added to a specific machine type. Examples of such actions are -# updating the firmware, connecting with remote devices or doing bed leveling. A machine action can also have a -# qml, which should contain a "Cura.MachineAction" item. When activated, the item will be displayed in a dialog -# and this object will be added as "manager" (so all pyqtSlot() functions can be called by calling manager.func()) class MachineAction(QObject, PluginObject): + """Machine actions are actions that are added to a specific machine type. + + Examples of such actions are updating the firmware, connecting with remote devices or doing bed leveling. A + machine action can also have a qml, which should contain a :py:class:`cura.MachineAction.MachineAction` item. + When activated, the item will be displayed in a dialog and this object will be added as "manager" (so all + pyqtSlot() functions can be called by calling manager.func()) + """ - ## Create a new Machine action. - # \param key unique key of the machine action - # \param label Human readable label used to identify the machine action. def __init__(self, key: str, label: str = "") -> None: + """Create a new Machine action. + + :param key: unique key of the machine action + :param label: Human readable label used to identify the machine action. + """ + super().__init__() self._key = key self._label = label @@ -34,10 +40,14 @@ class MachineAction(QObject, PluginObject): def getKey(self) -> str: return self._key - ## Whether this action needs to ask the user anything. - # If not, we shouldn't present the user with certain screens which otherwise show up. - # Defaults to true to be in line with the old behaviour. def needsUserInteraction(self) -> bool: + """Whether this action needs to ask the user anything. + + If not, we shouldn't present the user with certain screens which otherwise show up. + + :return: Defaults to true to be in line with the old behaviour. + """ + return True @pyqtProperty(str, notify = labelChanged) @@ -49,17 +59,24 @@ class MachineAction(QObject, PluginObject): self._label = label self.labelChanged.emit() - ## Reset the action to it's default state. - # This should not be re-implemented by child classes, instead re-implement _reset. - # /sa _reset @pyqtSlot() def reset(self) -> None: + """Reset the action to it's default state. + + This should not be re-implemented by child classes, instead re-implement _reset. + + :py:meth:`cura.MachineAction.MachineAction._reset` + """ + self._finished = False self._reset() - ## Protected implementation of reset. - # /sa reset() def _reset(self) -> None: + """Protected implementation of reset. + + See also :py:meth:`cura.MachineAction.MachineAction.reset` + """ + pass @pyqtSlot() @@ -72,8 +89,9 @@ class MachineAction(QObject, PluginObject): def finished(self) -> bool: return self._finished - ## Protected helper to create a view object based on provided QML. def _createViewFromQML(self) -> Optional["QObject"]: + """Protected helper to create a view object based on provided QML.""" + plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) if plugin_path is None: Logger.log("e", "Cannot create QML view: cannot find plugin path for plugin [%s]", self.getPluginId()) diff --git a/cura/Machines/ContainerNode.py b/cura/Machines/ContainerNode.py index 8a9ddcc39b..5341edd0c6 100644 --- a/cura/Machines/ContainerNode.py +++ b/cura/Machines/ContainerNode.py @@ -9,47 +9,59 @@ from UM.Logger import Logger from UM.Settings.InstanceContainer import InstanceContainer -## A node in the container tree. It represents one container. -# -# The container it represents is referenced by its container_id. During normal -# use of the tree, this container is not constructed. Only when parts of the -# tree need to get loaded in the container stack should it get constructed. class ContainerNode: - ## Creates a new node for the container tree. - # \param container_id The ID of the container that this node should - # represent. + """A node in the container tree. It represents one container. + + The container it represents is referenced by its container_id. During normal use of the tree, this container is + not constructed. Only when parts of the tree need to get loaded in the container stack should it get constructed. + """ + def __init__(self, container_id: str) -> None: + """Creates a new node for the container tree. + + :param container_id: The ID of the container that this node should represent. + """ + self.container_id = container_id self._container = None # type: Optional[InstanceContainer] self.children_map = {} # type: Dict[str, ContainerNode] # Mapping from container ID to container node. - ## Gets the metadata of the container that this node represents. - # Getting the metadata from the container directly is about 10x as fast. - # \return The metadata of the container in this node. def getMetadata(self) -> Dict[str, Any]: + """Gets the metadata of the container that this node represents. + + Getting the metadata from the container directly is about 10x as fast. + + :return: The metadata of the container in this node. + """ + return ContainerRegistry.getInstance().findContainersMetadata(id = self.container_id)[0] - ## Get an entry from the metadata of the container that this node contains. - # - # This is just a convenience function. - # \param entry The metadata entry key to return. - # \param default If the metadata is not present or the container is not - # found, the value of this default is returned. - # \return The value of the metadata entry, or the default if it was not - # present. def getMetaDataEntry(self, entry: str, default: Any = None) -> Any: + """Get an entry from the metadata of the container that this node contains. + + This is just a convenience function. + + :param entry: The metadata entry key to return. + :param default: If the metadata is not present or the container is not found, the value of this default is + returned. + + :return: The value of the metadata entry, or the default if it was not present. + """ + container_metadata = ContainerRegistry.getInstance().findContainersMetadata(id = self.container_id) if len(container_metadata) == 0: return default return container_metadata[0].get(entry, default) - ## The container that this node's container ID refers to. - # - # This can be used to finally instantiate the container in order to put it - # in the container stack. - # \return A container. @property def container(self) -> Optional[InstanceContainer]: + """The container that this node's container ID refers to. + + This can be used to finally instantiate the container in order to put it in the container stack. + + :return: A container. + """ + if not self._container: container_list = ContainerRegistry.getInstance().findInstanceContainers(id = self.container_id) if len(container_list) == 0: diff --git a/cura/Machines/ContainerTree.py b/cura/Machines/ContainerTree.py index a7bb0610bd..904f66e96e 100644 --- a/cura/Machines/ContainerTree.py +++ b/cura/Machines/ContainerTree.py @@ -19,17 +19,16 @@ if TYPE_CHECKING: from UM.Settings.ContainerStack import ContainerStack -## This class contains a look-up tree for which containers are available at -# which stages of configuration. -# -# The tree starts at the machine definitions. For every distinct definition -# there will be one machine node here. -# -# All of the fallbacks for material choices, quality choices, etc. should be -# encoded in this tree. There must always be at least one child node (for -# nodes that have children) but that child node may be a node representing the -# empty instance container. class ContainerTree: + """This class contains a look-up tree for which containers are available at which stages of configuration. + + The tree starts at the machine definitions. For every distinct definition there will be one machine node here. + + All of the fallbacks for material choices, quality choices, etc. should be encoded in this tree. There must + always be at least one child node (for nodes that have children) but that child node may be a node representing + the empty instance container. + """ + __instance = None # type: Optional["ContainerTree"] @classmethod @@ -43,13 +42,15 @@ class ContainerTree: self.materialsChanged = Signal() # Emitted when any of the material nodes in the tree got changed. cura.CuraApplication.CuraApplication.getInstance().initializationFinished.connect(self._onStartupFinished) # Start the background task to load more machine nodes after start-up is completed. - ## Get the quality groups available for the currently activated printer. - # - # This contains all quality groups, enabled or disabled. To check whether - # the quality group can be activated, test for the - # ``QualityGroup.is_available`` property. - # \return For every quality type, one quality group. def getCurrentQualityGroups(self) -> Dict[str, "QualityGroup"]: + """Get the quality groups available for the currently activated printer. + + This contains all quality groups, enabled or disabled. To check whether the quality group can be activated, + test for the ``QualityGroup.is_available`` property. + + :return: For every quality type, one quality group. + """ + global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() if global_stack is None: return {} @@ -58,14 +59,15 @@ class ContainerTree: extruder_enabled = [extruder.isEnabled for extruder in global_stack.extruderList] return self.machines[global_stack.definition.getId()].getQualityGroups(variant_names, material_bases, extruder_enabled) - ## Get the quality changes groups available for the currently activated - # printer. - # - # This contains all quality changes groups, enabled or disabled. To check - # whether the quality changes group can be activated, test for the - # ``QualityChangesGroup.is_available`` property. - # \return A list of all quality changes groups. def getCurrentQualityChangesGroups(self) -> List["QualityChangesGroup"]: + """Get the quality changes groups available for the currently activated printer. + + This contains all quality changes groups, enabled or disabled. To check whether the quality changes group can + be activated, test for the ``QualityChangesGroup.is_available`` property. + + :return: A list of all quality changes groups. + """ + global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() if global_stack is None: return [] @@ -74,31 +76,43 @@ class ContainerTree: extruder_enabled = [extruder.isEnabled for extruder in global_stack.extruderList] return self.machines[global_stack.definition.getId()].getQualityChangesGroups(variant_names, material_bases, extruder_enabled) - ## Ran after completely starting up the application. def _onStartupFinished(self) -> None: + """Ran after completely starting up the application.""" + currently_added = ContainerRegistry.getInstance().findContainerStacks() # Find all currently added global stacks. JobQueue.getInstance().add(self._MachineNodeLoadJob(self, currently_added)) - ## Dictionary-like object that contains the machines. - # - # This handles the lazy loading of MachineNodes. class _MachineNodeMap: + """Dictionary-like object that contains the machines. + + This handles the lazy loading of MachineNodes. + """ + def __init__(self) -> None: self._machines = {} # type: Dict[str, MachineNode] - ## Returns whether a printer with a certain definition ID exists. This - # is regardless of whether or not the printer is loaded yet. - # \param definition_id The definition to look for. - # \return Whether or not a printer definition exists with that name. def __contains__(self, definition_id: str) -> bool: + """Returns whether a printer with a certain definition ID exists. + + This is regardless of whether or not the printer is loaded yet. + + :param definition_id: The definition to look for. + + :return: Whether or not a printer definition exists with that name. + """ + return len(ContainerRegistry.getInstance().findContainersMetadata(id = definition_id)) > 0 - ## Returns a machine node for the specified definition ID. - # - # If the machine node wasn't loaded yet, this will load it lazily. - # \param definition_id The definition to look for. - # \return A machine node for that definition. def __getitem__(self, definition_id: str) -> MachineNode: + """Returns a machine node for the specified definition ID. + + If the machine node wasn't loaded yet, this will load it lazily. + + :param definition_id: The definition to look for. + + :return: A machine node for that definition. + """ + if definition_id not in self._machines: start_time = time.time() self._machines[definition_id] = MachineNode(definition_id) @@ -106,46 +120,58 @@ class ContainerTree: Logger.log("d", "Adding container tree for {definition_id} took {duration} seconds.".format(definition_id = definition_id, duration = time.time() - start_time)) return self._machines[definition_id] - ## Gets a machine node for the specified definition ID, with default. - # - # The default is returned if there is no definition with the specified - # ID. If the machine node wasn't loaded yet, this will load it lazily. - # \param definition_id The definition to look for. - # \param default The machine node to return if there is no machine - # with that definition (can be ``None`` optionally or if not - # provided). - # \return A machine node for that definition, or the default if there - # is no definition with the provided definition_id. def get(self, definition_id: str, default: Optional[MachineNode] = None) -> Optional[MachineNode]: + """Gets a machine node for the specified definition ID, with default. + + The default is returned if there is no definition with the specified ID. If the machine node wasn't + loaded yet, this will load it lazily. + + :param definition_id: The definition to look for. + :param default: The machine node to return if there is no machine with that definition (can be ``None`` + optionally or if not provided). + + :return: A machine node for that definition, or the default if there is no definition with the provided + definition_id. + """ + if definition_id not in self: return default return self[definition_id] - ## Returns whether we've already cached this definition's node. - # \param definition_id The definition that we may have cached. - # \return ``True`` if it's cached. def is_loaded(self, definition_id: str) -> bool: + """Returns whether we've already cached this definition's node. + + :param definition_id: The definition that we may have cached. + + :return: ``True`` if it's cached. + """ + return definition_id in self._machines - ## Pre-loads all currently added printers as a background task so that - # switching printers in the interface is faster. class _MachineNodeLoadJob(Job): - ## Creates a new background task. - # \param tree_root The container tree instance. This cannot be - # obtained through the singleton static function since the instance - # may not yet be constructed completely. - # \param container_stacks All of the stacks to pre-load the container - # trees for. This needs to be provided from here because the stacks - # need to be constructed on the main thread because they are QObject. + """Pre-loads all currently added printers as a background task so that switching printers in the interface is + faster. + """ + def __init__(self, tree_root: "ContainerTree", container_stacks: List["ContainerStack"]) -> None: + """Creates a new background task. + + :param tree_root: The container tree instance. This cannot be obtained through the singleton static + function since the instance may not yet be constructed completely. + :param container_stacks: All of the stacks to pre-load the container trees for. This needs to be provided + from here because the stacks need to be constructed on the main thread because they are QObject. + """ + self.tree_root = tree_root self.container_stacks = container_stacks super().__init__() - ## Starts the background task. - # - # The ``JobQueue`` will schedule this on a different thread. def run(self) -> None: + """Starts the background task. + + The ``JobQueue`` will schedule this on a different thread. + """ + Logger.log("d", "Started background loading of MachineNodes") for stack in self.container_stacks: # Load all currently-added containers. if not isinstance(stack, GlobalStack): continue @@ -156,3 +182,4 @@ class ContainerTree: definition_id = stack.definition.getId() if not self.tree_root.machines.is_loaded(definition_id): _ = self.tree_root.machines[definition_id] + Logger.log("d", "All MachineNode loading completed")
\ No newline at end of file diff --git a/cura/Machines/IntentNode.py b/cura/Machines/IntentNode.py index 2b3a596f81..949a5d3a2b 100644 --- a/cura/Machines/IntentNode.py +++ b/cura/Machines/IntentNode.py @@ -11,10 +11,12 @@ if TYPE_CHECKING: from cura.Machines.QualityNode import QualityNode -## This class represents an intent profile in the container tree. -# -# This class has no more subnodes. class IntentNode(ContainerNode): + """This class represents an intent profile in the container tree. + + This class has no more subnodes. + """ + def __init__(self, container_id: str, quality: "QualityNode") -> None: super().__init__(container_id) self.quality = quality diff --git a/cura/Machines/MachineErrorChecker.py b/cura/Machines/MachineErrorChecker.py index 7a5291dac5..818d62de7c 100644 --- a/cura/Machines/MachineErrorChecker.py +++ b/cura/Machines/MachineErrorChecker.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import time @@ -13,16 +13,17 @@ from UM.Settings.SettingDefinition import SettingDefinition from UM.Settings.Validator import ValidatorState import cura.CuraApplication -# -# This class performs setting error checks for the currently active machine. -# -# The whole error checking process is pretty heavy which can take ~0.5 secs, so it can cause GUI to lag. -# The idea here is to split the whole error check into small tasks, each of which only checks a single setting key -# in a stack. According to my profiling results, the maximal runtime for such a sub-task is <0.03 secs, which should -# be good enough. Moreover, if any changes happened to the machine, we can cancel the check in progress without wait -# for it to finish the complete work. -# + + class MachineErrorChecker(QObject): + """This class performs setting error checks for the currently active machine. + + The whole error checking process is pretty heavy which can take ~0.5 secs, so it can cause GUI to lag. The idea + here is to split the whole error check into small tasks, each of which only checks a single setting key in a + stack. According to my profiling results, the maximal runtime for such a sub-task is <0.03 secs, which should be + good enough. Moreover, if any changes happened to the machine, we can cancel the check in progress without wait + for it to finish the complete work. + """ def __init__(self, parent: Optional[QObject] = None) -> None: super().__init__(parent) @@ -50,6 +51,8 @@ class MachineErrorChecker(QObject): self._error_check_timer.setInterval(100) self._error_check_timer.setSingleShot(True) + self._keys_to_check = set() # type: Set[str] + def initialize(self) -> None: self._error_check_timer.timeout.connect(self._rescheduleCheck) @@ -92,24 +95,38 @@ class MachineErrorChecker(QObject): def needToWaitForResult(self) -> bool: return self._need_to_check or self._check_in_progress - # Start the error check for property changed - # this is seperate from the startErrorCheck because it ignores a number property types def startErrorCheckPropertyChanged(self, key: str, property_name: str) -> None: + """Start the error check for property changed + + this is seperate from the startErrorCheck because it ignores a number property types + + :param key: + :param property_name: + """ + if property_name != "value": return + self._keys_to_check.add(key) self.startErrorCheck() - # Starts the error check timer to schedule a new error check. def startErrorCheck(self, *args: Any) -> None: + """Starts the error check timer to schedule a new error check. + + :param args: + """ + if not self._check_in_progress: self._need_to_check = True self.needToWaitForResultChanged.emit() self._error_check_timer.start() - # This function is called by the timer to reschedule a new error check. - # If there is no check in progress, it will start a new one. If there is any, it sets the "_need_to_check" flag - # to notify the current check to stop and start a new one. def _rescheduleCheck(self) -> None: + """This function is called by the timer to reschedule a new error check. + + If there is no check in progress, it will start a new one. If there is any, it sets the "_need_to_check" flag + to notify the current check to stop and start a new one. + """ + if self._check_in_progress and not self._need_to_check: self._need_to_check = True self.needToWaitForResultChanged.emit() @@ -127,7 +144,10 @@ class MachineErrorChecker(QObject): # Populate the (stack, key) tuples to check self._stacks_and_keys_to_check = deque() for stack in global_stack.extruderList: - for key in stack.getAllKeys(): + if not self._keys_to_check: + self._keys_to_check = stack.getAllKeys() + + for key in self._keys_to_check: self._stacks_and_keys_to_check.append((stack, key)) self._application.callLater(self._checkStack) @@ -168,18 +188,25 @@ class MachineErrorChecker(QObject): validator = validator_type(key) validation_state = validator(stack) if validation_state in (ValidatorState.Exception, ValidatorState.MaximumError, ValidatorState.MinimumError, ValidatorState.Invalid): - # Finish - self._setResult(True) + # Since we don't know if any of the settings we didn't check is has an error value, store the list for the + # next check. + keys_to_recheck = {setting_key for stack, setting_key in self._stacks_and_keys_to_check} + keys_to_recheck.add(key) + self._setResult(True, keys_to_recheck = keys_to_recheck) return # Schedule the check for the next key self._application.callLater(self._checkStack) - def _setResult(self, result: bool) -> None: + def _setResult(self, result: bool, keys_to_recheck = None) -> None: if result != self._has_errors: self._has_errors = result self.hasErrorUpdated.emit() self._machine_manager.stacksValidationChanged.emit() + if keys_to_recheck is None: + self._keys_to_check = set() + else: + self._keys_to_check = keys_to_recheck self._need_to_check = False self._check_in_progress = False self.needToWaitForResultChanged.emit() diff --git a/cura/Machines/MachineNode.py b/cura/Machines/MachineNode.py index 0974c3dca7..d4706ae5ef 100644 --- a/cura/Machines/MachineNode.py +++ b/cura/Machines/MachineNode.py @@ -17,10 +17,12 @@ from cura.Machines.VariantNode import VariantNode import UM.FlameProfiler -## This class represents a machine in the container tree. -# -# The subnodes of these nodes are variants. class MachineNode(ContainerNode): + """This class represents a machine in the container tree. + + The subnodes of these nodes are variants. + """ + def __init__(self, container_id: str) -> None: super().__init__(container_id) self.variants = {} # type: Dict[str, VariantNode] # Mapping variant names to their nodes. @@ -47,20 +49,21 @@ class MachineNode(ContainerNode): self._loadAll() - ## Get the available quality groups for this machine. - # - # This returns all quality groups, regardless of whether they are - # available to the combination of extruders or not. On the resulting - # quality groups, the is_available property is set to indicate whether the - # quality group can be selected according to the combination of extruders - # in the parameters. - # \param variant_names The names of the variants loaded in each extruder. - # \param material_bases The base file names of the materials loaded in - # each extruder. - # \param extruder_enabled Whether or not the extruders are enabled. This - # allows the function to set the is_available properly. - # \return For each available quality type, a QualityGroup instance. def getQualityGroups(self, variant_names: List[str], material_bases: List[str], extruder_enabled: List[bool]) -> Dict[str, QualityGroup]: + """Get the available quality groups for this machine. + + This returns all quality groups, regardless of whether they are available to the combination of extruders or + not. On the resulting quality groups, the is_available property is set to indicate whether the quality group + can be selected according to the combination of extruders in the parameters. + + :param variant_names: The names of the variants loaded in each extruder. + :param material_bases: The base file names of the materials loaded in each extruder. + :param extruder_enabled: Whether or not the extruders are enabled. This allows the function to set the + is_available properly. + + :return: For each available quality type, a QualityGroup instance. + """ + if len(variant_names) != len(material_bases) or len(variant_names) != len(extruder_enabled): Logger.log("e", "The number of extruders in the list of variants (" + str(len(variant_names)) + ") is not equal to the number of extruders in the list of materials (" + str(len(material_bases)) + ") or the list of enabled extruders (" + str(len(extruder_enabled)) + ").") return {} @@ -98,28 +101,26 @@ class MachineNode(ContainerNode): quality_groups[quality_type].is_available = True return quality_groups - ## Returns all of the quality changes groups available to this printer. - # - # The quality changes groups store which quality type and intent category - # they were made for, but not which material and nozzle. Instead for the - # quality type and intent category, the quality changes will always be - # available but change the quality type and intent category when - # activated. - # - # The quality changes group does depend on the printer: Which quality - # definition is used. - # - # The quality changes groups that are available do depend on the quality - # types that are available, so it must still be known which extruders are - # enabled and which materials and variants are loaded in them. This allows - # setting the correct is_available flag. - # \param variant_names The names of the variants loaded in each extruder. - # \param material_bases The base file names of the materials loaded in - # each extruder. - # \param extruder_enabled For each extruder whether or not they are - # enabled. - # \return List of all quality changes groups for the printer. def getQualityChangesGroups(self, variant_names: List[str], material_bases: List[str], extruder_enabled: List[bool]) -> List[QualityChangesGroup]: + """Returns all of the quality changes groups available to this printer. + + The quality changes groups store which quality type and intent category they were made for, but not which + material and nozzle. Instead for the quality type and intent category, the quality changes will always be + available but change the quality type and intent category when activated. + + The quality changes group does depend on the printer: Which quality definition is used. + + The quality changes groups that are available do depend on the quality types that are available, so it must + still be known which extruders are enabled and which materials and variants are loaded in them. This allows + setting the correct is_available flag. + + :param variant_names: The names of the variants loaded in each extruder. + :param material_bases: The base file names of the materials loaded in each extruder. + :param extruder_enabled: For each extruder whether or not they are enabled. + + :return: List of all quality changes groups for the printer. + """ + machine_quality_changes = ContainerRegistry.getInstance().findContainersMetadata(type = "quality_changes", definition = self.quality_definition) # All quality changes for each extruder. groups_by_name = {} #type: Dict[str, QualityChangesGroup] # Group quality changes profiles by their display name. The display name must be unique for quality changes. This finds profiles that belong together in a group. @@ -134,9 +135,7 @@ class MachineNode(ContainerNode): groups_by_name[name] = QualityChangesGroup(name, quality_type = quality_changes["quality_type"], intent_category = quality_changes.get("intent_category", "default"), parent = CuraApplication.getInstance()) - # CURA-6882 - # Custom qualities are always available, even if they are based on the "not supported" profile. - groups_by_name[name].is_available = True + elif groups_by_name[name].intent_category == "default": # Intent category should be stored as "default" if everything is default or as the intent if any of the extruder have an actual intent. groups_by_name[name].intent_category = quality_changes.get("intent_category", "default") @@ -145,20 +144,33 @@ class MachineNode(ContainerNode): else: # Global profile. groups_by_name[name].metadata_for_global = quality_changes + quality_groups = self.getQualityGroups(variant_names, material_bases, extruder_enabled) + for quality_changes_group in groups_by_name.values(): + if quality_changes_group.quality_type not in quality_groups: + if quality_changes_group.quality_type == "not_supported": + # Quality changes based on an empty profile are always available. + quality_changes_group.is_available = True + else: + quality_changes_group.is_available = False + else: + # Quality changes group is available iff the quality group it depends on is available. Irrespective of whether the intent category is available. + quality_changes_group.is_available = quality_groups[quality_changes_group.quality_type].is_available + return list(groups_by_name.values()) - ## Gets the preferred global quality node, going by the preferred quality - # type. - # - # If the preferred global quality is not in there, an arbitrary global - # quality is taken. - # If there are no global qualities, an empty quality is returned. def preferredGlobalQuality(self) -> "QualityNode": + """Gets the preferred global quality node, going by the preferred quality type. + + If the preferred global quality is not in there, an arbitrary global quality is taken. If there are no global + qualities, an empty quality is returned. + """ + return self.global_qualities.get(self.preferred_quality_type, next(iter(self.global_qualities.values()))) - ## (Re)loads all variants under this printer. @UM.FlameProfiler.profile def _loadAll(self) -> None: + """(Re)loads all variants under this printer.""" + container_registry = ContainerRegistry.getInstance() if not self.has_variants: self.variants["empty"] = VariantNode("empty_variant", machine = self) @@ -171,6 +183,10 @@ class MachineNode(ContainerNode): if variant_name not in self.variants: self.variants[variant_name] = VariantNode(variant["id"], machine = self) self.variants[variant_name].materialsChanged.connect(self.materialsChanged) + else: + # Force reloading the materials if the variant already exists or else materals won't be loaded + # when the G-Code flavor changes --> CURA-7354 + self.variants[variant_name]._loadAll() if not self.variants: self.variants["empty"] = VariantNode("empty_variant", machine = self) diff --git a/cura/Machines/MaterialGroup.py b/cura/Machines/MaterialGroup.py index e05647e674..2ff4b99c80 100644 --- a/cura/Machines/MaterialGroup.py +++ b/cura/Machines/MaterialGroup.py @@ -7,18 +7,21 @@ if TYPE_CHECKING: from cura.Machines.MaterialNode import MaterialNode -## A MaterialGroup represents a group of material InstanceContainers that are derived from a single material profile. -# The main InstanceContainer which has the ID of the material profile file name is called the "root_material". For -# example: "generic_abs" is the root material (ID) of "generic_abs_ultimaker3" and "generic_abs_ultimaker3_AA_0.4", -# and "generic_abs_ultimaker3" and "generic_abs_ultimaker3_AA_0.4" are derived materials of "generic_abs". -# -# Using "generic_abs" as an example, the MaterialGroup for "generic_abs" will contain the following information: -# - name: "generic_abs", root_material_id -# - root_material_node: MaterialNode of "generic_abs" -# - derived_material_node_list: A list of MaterialNodes that are derived from "generic_abs", -# so "generic_abs_ultimaker3", "generic_abs_ultimaker3_AA_0.4", etc. -# class MaterialGroup: + """A MaterialGroup represents a group of material InstanceContainers that are derived from a single material profile. + + The main InstanceContainer which has the ID of the material profile file name is called the "root_material". For + example: "generic_abs" is the root material (ID) of "generic_abs_ultimaker3" and "generic_abs_ultimaker3_AA_0.4", + and "generic_abs_ultimaker3" and "generic_abs_ultimaker3_AA_0.4" are derived materials of "generic_abs". + + Using "generic_abs" as an example, the MaterialGroup for "generic_abs" will contain the following information: + - name: "generic_abs", root_material_id + - root_material_node: MaterialNode of "generic_abs" + - derived_material_node_list: A list of MaterialNodes that are derived from "generic_abs", so + "generic_abs_ultimaker3", "generic_abs_ultimaker3_AA_0.4", etc. + + """ + __slots__ = ("name", "is_read_only", "root_material_node", "derived_material_node_list") def __init__(self, name: str, root_material_node: "MaterialNode") -> None: diff --git a/cura/Machines/MaterialNode.py b/cura/Machines/MaterialNode.py index dcd4adcfdb..c78c6aff03 100644 --- a/cura/Machines/MaterialNode.py +++ b/cura/Machines/MaterialNode.py @@ -15,10 +15,12 @@ if TYPE_CHECKING: from cura.Machines.VariantNode import VariantNode -## Represents a material in the container tree. -# -# Its subcontainers are quality profiles. class MaterialNode(ContainerNode): + """Represents a material in the container tree. + + Its subcontainers are quality profiles. + """ + def __init__(self, container_id: str, variant: "VariantNode") -> None: super().__init__(container_id) self.variant = variant @@ -34,16 +36,16 @@ class MaterialNode(ContainerNode): container_registry.containerRemoved.connect(self._onRemoved) container_registry.containerMetaDataChanged.connect(self._onMetadataChanged) - ## Finds the preferred quality for this printer with this material and this - # variant loaded. - # - # If the preferred quality is not available, an arbitrary quality is - # returned. If there is a configuration mistake (like a typo in the - # preferred quality) this returns a random available quality. If there are - # no available qualities, this will return the empty quality node. - # \return The node for the preferred quality, or any arbitrary quality if - # there is no match. def preferredQuality(self) -> QualityNode: + """Finds the preferred quality for this printer with this material and this variant loaded. + + If the preferred quality is not available, an arbitrary quality is returned. If there is a configuration + mistake (like a typo in the preferred quality) this returns a random available quality. If there are no + available qualities, this will return the empty quality node. + + :return: The node for the preferred quality, or any arbitrary quality if there is no match. + """ + for quality_id, quality_node in self.qualities.items(): if self.variant.machine.preferred_quality_type == quality_node.quality_type: return quality_node @@ -107,10 +109,13 @@ class MaterialNode(ContainerNode): if not self.qualities: self.qualities["empty_quality"] = QualityNode("empty_quality", parent = self) - ## Triggered when any container is removed, but only handles it when the - # container is removed that this node represents. - # \param container The container that was allegedly removed. def _onRemoved(self, container: ContainerInterface) -> None: + """Triggered when any container is removed, but only handles it when the container is removed that this node + represents. + + :param container: The container that was allegedly removed. + """ + if container.getId() == self.container_id: # Remove myself from my parent. if self.base_file in self.variant.materials: @@ -119,13 +124,15 @@ class MaterialNode(ContainerNode): self.variant.materials["empty_material"] = MaterialNode("empty_material", variant = self.variant) self.materialChanged.emit(self) - ## Triggered when any metadata changed in any container, but only handles - # it when the metadata of this node is changed. - # \param container The container whose metadata changed. - # \param kwargs Key-word arguments provided when changing the metadata. - # These are ignored. As far as I know they are never provided to this - # call. def _onMetadataChanged(self, container: ContainerInterface, **kwargs: Any) -> None: + """Triggered when any metadata changed in any container, but only handles it when the metadata of this node is + changed. + + :param container: The container whose metadata changed. + :param kwargs: Key-word arguments provided when changing the metadata. These are ignored. As far as I know they + are never provided to this call. + """ + if container.getId() != self.container_id: return diff --git a/cura/Machines/Models/BaseMaterialsModel.py b/cura/Machines/Models/BaseMaterialsModel.py index 5e672faa12..776d540867 100644 --- a/cura/Machines/Models/BaseMaterialsModel.py +++ b/cura/Machines/Models/BaseMaterialsModel.py @@ -13,11 +13,13 @@ from cura.Machines.ContainerTree import ContainerTree from cura.Machines.MaterialNode import MaterialNode from cura.Settings.CuraContainerRegistry import CuraContainerRegistry -## This is the base model class for GenericMaterialsModel and MaterialBrandsModel. -# Those 2 models are used by the material drop down menu to show generic materials and branded materials separately. -# The extruder position defined here is being used to bound a menu to the correct extruder. This is used in the top -# bar menu "Settings" -> "Extruder nr" -> "Material" -> this menu class BaseMaterialsModel(ListModel): + """This is the base model class for GenericMaterialsModel and MaterialBrandsModel. + + Those 2 models are used by the material drop down menu to show generic materials and branded materials + separately. The extruder position defined here is being used to bound a menu to the correct extruder. This is + used in the top bar menu "Settings" -> "Extruder nr" -> "Material" -> this menu + """ extruderPositionChanged = pyqtSignal() enabledChanged = pyqtSignal() @@ -121,10 +123,13 @@ class BaseMaterialsModel(ListModel): def enabled(self): return self._enabled - ## Triggered when a list of materials changed somewhere in the container - # tree. This change may trigger an _update() call when the materials - # changed for the configuration that this model is looking for. def _materialsListChanged(self, material: MaterialNode) -> None: + """Triggered when a list of materials changed somewhere in the container + + tree. This change may trigger an _update() call when the materials changed for the configuration that this + model is looking for. + """ + if self._extruder_stack is None: return if material.variant.container_id != self._extruder_stack.variant.getId(): @@ -136,23 +141,25 @@ class BaseMaterialsModel(ListModel): return self._onChanged() - ## Triggered when the list of favorite materials is changed. def _favoritesChanged(self, material_base_file: str) -> None: + """Triggered when the list of favorite materials is changed.""" + if material_base_file in self._available_materials: self._onChanged() - ## This is an abstract method that needs to be implemented by the specific - # models themselves. def _update(self): + """This is an abstract method that needs to be implemented by the specific models themselves. """ + self._favorite_ids = set(cura.CuraApplication.CuraApplication.getInstance().getPreferences().getValue("cura/favorite_materials").split(";")) # Update the available materials (ContainerNode) for the current active machine and extruder setup. global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() - if not global_stack.hasMaterials: + if not global_stack or not global_stack.hasMaterials: return # There are no materials for this machine, so nothing to do. - extruder_stack = global_stack.extruders.get(str(self._extruder_position)) - if not extruder_stack: + extruder_list = global_stack.extruderList + if self._extruder_position > len(extruder_list): return + extruder_stack = extruder_list[self._extruder_position] nozzle_name = extruder_stack.variant.getName() machine_node = ContainerTree.getInstance().machines[global_stack.definition.getId()] if nozzle_name not in machine_node.variants: @@ -163,23 +170,23 @@ class BaseMaterialsModel(ListModel): approximate_material_diameter = extruder_stack.getApproximateMaterialDiameter() self._available_materials = {key: material for key, material in materials.items() if float(material.getMetaDataEntry("approximate_diameter", -1)) == approximate_material_diameter} - ## This method is used by all material models in the beginning of the - # _update() method in order to prevent errors. It's the same in all models - # so it's placed here for easy access. def _canUpdate(self): + """This method is used by all material models in the beginning of the _update() method in order to prevent + errors. It's the same in all models so it's placed here for easy access. """ + global_stack = self._machine_manager.activeMachine if global_stack is None or not self._enabled: return False - extruder_position = str(self._extruder_position) - if extruder_position not in global_stack.extruders: + if self._extruder_position >= len(global_stack.extruderList): return False return True - ## This is another convenience function which is shared by all material - # models so it's put here to avoid having so much duplicated code. def _createMaterialItem(self, root_material_id, container_node): + """This is another convenience function which is shared by all material models so it's put here to avoid having + so much duplicated code. """ + metadata_list = CuraContainerRegistry.getInstance().findContainersMetadata(id = container_node.container_id) if not metadata_list: return None diff --git a/cura/Machines/Models/CustomQualityProfilesDropDownMenuModel.py b/cura/Machines/Models/CustomQualityProfilesDropDownMenuModel.py index 1ab7e21700..ce4b87da2b 100644 --- a/cura/Machines/Models/CustomQualityProfilesDropDownMenuModel.py +++ b/cura/Machines/Models/CustomQualityProfilesDropDownMenuModel.py @@ -14,9 +14,8 @@ if TYPE_CHECKING: from UM.Settings.Interfaces import ContainerInterface -## This model is used for the custom profile items in the profile drop down -# menu. class CustomQualityProfilesDropDownMenuModel(QualityProfilesDropDownMenuModel): + """This model is used for the custom profile items in the profile drop down menu.""" def __init__(self, parent: Optional["QObject"] = None) -> None: super().__init__(parent) diff --git a/cura/Machines/Models/DiscoveredCloudPrintersModel.py b/cura/Machines/Models/DiscoveredCloudPrintersModel.py new file mode 100644 index 0000000000..692ed49593 --- /dev/null +++ b/cura/Machines/Models/DiscoveredCloudPrintersModel.py @@ -0,0 +1,77 @@ +from typing import Optional, TYPE_CHECKING, List, Dict + +from PyQt5.QtCore import QObject, pyqtSlot, Qt, pyqtSignal, pyqtProperty + +from UM.Qt.ListModel import ListModel + +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + + +class DiscoveredCloudPrintersModel(ListModel): + """Model used to inform the application about newly added cloud printers, which are discovered from the user's + account """ + + DeviceKeyRole = Qt.UserRole + 1 + DeviceNameRole = Qt.UserRole + 2 + DeviceTypeRole = Qt.UserRole + 3 + DeviceFirmwareVersionRole = Qt.UserRole + 4 + + cloudPrintersDetectedChanged = pyqtSignal(bool) + + def __init__(self, application: "CuraApplication", parent: Optional["QObject"] = None) -> None: + super().__init__(parent) + + self.addRoleName(self.DeviceKeyRole, "key") + self.addRoleName(self.DeviceNameRole, "name") + self.addRoleName(self.DeviceTypeRole, "machine_type") + self.addRoleName(self.DeviceFirmwareVersionRole, "firmware_version") + + self._discovered_cloud_printers_list = [] # type: List[Dict[str, str]] + self._application = application # type: CuraApplication + + def addDiscoveredCloudPrinters(self, new_devices: List[Dict[str, str]]) -> None: + """Adds all the newly discovered cloud printers into the DiscoveredCloudPrintersModel. + + Example new_devices entry: + + .. code-block:: python + + { + "key": "YjW8pwGYcaUvaa0YgVyWeFkX3z", + "name": "NG 001", + "machine_type": "Ultimaker S5", + "firmware_version": "5.5.12.202001" + } + + :param new_devices: List of dictionaries which contain information about added cloud printers. + + :return: None + """ + + self._discovered_cloud_printers_list.extend(new_devices) + self._update() + + # Inform whether new cloud printers have been detected. If they have, the welcome wizard can close. + self.cloudPrintersDetectedChanged.emit(len(new_devices) > 0) + + @pyqtSlot() + def clear(self) -> None: + """Clears the contents of the DiscoveredCloudPrintersModel. + + :return: None + """ + + self._discovered_cloud_printers_list = [] + self._update() + self.cloudPrintersDetectedChanged.emit(False) + + def _update(self) -> None: + """Sorts the newly discovered cloud printers by name and then updates the ListModel. + + :return: None + """ + + items = self._discovered_cloud_printers_list[:] + items.sort(key = lambda k: k["name"]) + self.setItems(items) diff --git a/cura/Machines/Models/DiscoveredPrintersModel.py b/cura/Machines/Models/DiscoveredPrintersModel.py index 67d9c19d7e..459ec4d795 100644 --- a/cura/Machines/Models/DiscoveredPrintersModel.py +++ b/cura/Machines/Models/DiscoveredPrintersModel.py @@ -72,8 +72,6 @@ class DiscoveredPrinter(QObject): # Human readable machine type string @pyqtProperty(str, notify = machineTypeChanged) def readableMachineType(self) -> str: - from cura.CuraApplication import CuraApplication - machine_manager = CuraApplication.getInstance().getMachineManager() # In NetworkOutputDevice, when it updates a printer information, it updates the machine type using the field # "machine_variant", and for some reason, it's not the machine type ID/codename/... but a human-readable string # like "Ultimaker 3". The code below handles this case. @@ -117,12 +115,11 @@ class DiscoveredPrinter(QObject): return catalog.i18nc("@label", "Available networked printers") -# -# Discovered printers are all the printers that were found on the network, which provide a more convenient way -# to add networked printers (Plugin finds a bunch of printers, user can select one from the list, plugin can then -# add that printer to Cura as the active one). -# class DiscoveredPrintersModel(QObject): + """Discovered printers are all the printers that were found on the network, which provide a more convenient way to + add networked printers (Plugin finds a bunch of printers, user can select one from the list, plugin can then add + that printer to Cura as the active one). + """ def __init__(self, application: "CuraApplication", parent: Optional["QObject"] = None) -> None: super().__init__(parent) @@ -131,6 +128,7 @@ class DiscoveredPrintersModel(QObject): self._discovered_printer_by_ip_dict = dict() # type: Dict[str, DiscoveredPrinter] self._plugin_for_manual_device = None # type: Optional[OutputDevicePlugin] + self._network_plugin_queue = [] # type: List[OutputDevicePlugin] self._manual_device_address = "" self._manual_device_request_timeout_in_seconds = 5 # timeout for adding a manual device in seconds @@ -155,20 +153,25 @@ class DiscoveredPrintersModel(QObject): all_plugins_dict = self._application.getOutputDeviceManager().getAllOutputDevicePlugins() - can_add_manual_plugins = [item for item in filter( + self._network_plugin_queue = [item for item in filter( lambda plugin_item: plugin_item.canAddManualDevice(address) in priority_order, all_plugins_dict.values())] - if not can_add_manual_plugins: + if not self._network_plugin_queue: Logger.log("d", "Could not find a plugin to accept adding %s manually via address.", address) return - plugin = max(can_add_manual_plugins, key = lambda p: priority_order.index(p.canAddManualDevice(address))) - self._plugin_for_manual_device = plugin - self._plugin_for_manual_device.addManualDevice(address, callback = self._onManualDeviceRequestFinished) - self._manual_device_address = address - self._manual_device_request_timer.start() - self.hasManualDeviceRequestInProgressChanged.emit() + self._attemptToAddManualDevice(address) + + def _attemptToAddManualDevice(self, address: str) -> None: + if self._network_plugin_queue: + self._plugin_for_manual_device = self._network_plugin_queue.pop() + Logger.log("d", "Network plugin %s: attempting to add manual device with address %s.", + self._plugin_for_manual_device.getId(), address) + self._plugin_for_manual_device.addManualDevice(address, callback=self._onManualDeviceRequestFinished) + self._manual_device_address = address + self._manual_device_request_timer.start() + self.hasManualDeviceRequestInProgressChanged.emit() @pyqtSlot() def cancelCurrentManualDeviceRequest(self) -> None: @@ -183,8 +186,11 @@ class DiscoveredPrintersModel(QObject): self.manualDeviceRequestFinished.emit(False) def _onManualRequestTimeout(self) -> None: - Logger.log("w", "Manual printer [%s] request timed out. Cancel the current request.", self._manual_device_address) + address = self._manual_device_address + Logger.log("w", "Manual printer [%s] request timed out. Cancel the current request.", address) self.cancelCurrentManualDeviceRequest() + if self._network_plugin_queue: + self._attemptToAddManualDevice(address) hasManualDeviceRequestInProgressChanged = pyqtSignal() @@ -200,11 +206,13 @@ class DiscoveredPrintersModel(QObject): self._manual_device_address = "" self.hasManualDeviceRequestInProgressChanged.emit() self.manualDeviceRequestFinished.emit(success) + if not success and self._network_plugin_queue: + self._attemptToAddManualDevice(address) @pyqtProperty("QVariantMap", notify = discoveredPrintersChanged) def discoveredPrintersByAddress(self) -> Dict[str, DiscoveredPrinter]: return self._discovered_printer_by_ip_dict - + @pyqtProperty("QVariantList", notify = discoveredPrintersChanged) def discoveredPrinters(self) -> List["DiscoveredPrinter"]: item_list = list( @@ -256,8 +264,14 @@ class DiscoveredPrintersModel(QObject): del self._discovered_printer_by_ip_dict[ip_address] self.discoveredPrintersChanged.emit() - # A convenience function for QML to create a machine (GlobalStack) out of the given discovered printer. - # This function invokes the given discovered printer's "create_callback" to do this. + @pyqtSlot("QVariant") def createMachineFromDiscoveredPrinter(self, discovered_printer: "DiscoveredPrinter") -> None: + """A convenience function for QML to create a machine (GlobalStack) out of the given discovered printer. + + This function invokes the given discovered printer's "create_callback" to do this + + :param discovered_printer: + """ + discovered_printer.create_callback(discovered_printer.getKey()) diff --git a/cura/Machines/Models/ExtrudersModel.py b/cura/Machines/Models/ExtrudersModel.py index 9eee7f5f9e..98865ed37e 100644 --- a/cura/Machines/Models/ExtrudersModel.py +++ b/cura/Machines/Models/ExtrudersModel.py @@ -15,27 +15,27 @@ if TYPE_CHECKING: catalog = i18nCatalog("cura") -## Model that holds extruders. -# -# This model is designed for use by any list of extruders, but specifically -# intended for drop-down lists of the current machine's extruders in place of -# settings. class ExtrudersModel(ListModel): + """Model that holds extruders. + + This model is designed for use by any list of extruders, but specifically intended for drop-down lists of the + current machine's extruders in place of settings. + """ + # The ID of the container stack for the extruder. IdRole = Qt.UserRole + 1 - ## Human-readable name of the extruder. NameRole = Qt.UserRole + 2 + """Human-readable name of the extruder.""" - ## Colour of the material loaded in the extruder. ColorRole = Qt.UserRole + 3 + """Colour of the material loaded in the extruder.""" - ## Index of the extruder, which is also the value of the setting itself. - # - # An index of 0 indicates the first extruder, an index of 1 the second - # one, and so on. This is the value that will be saved in instance - # containers. IndexRole = Qt.UserRole + 4 + """Index of the extruder, which is also the value of the setting itself. + + An index of 0 indicates the first extruder, an index of 1 the second one, and so on. This is the value that will + be saved in instance containers. """ # The ID of the definition of the extruder. DefinitionRole = Qt.UserRole + 5 @@ -50,18 +50,18 @@ class ExtrudersModel(ListModel): MaterialBrandRole = Qt.UserRole + 9 ColorNameRole = Qt.UserRole + 10 - ## Is the extruder enabled? EnabledRole = Qt.UserRole + 11 + """Is the extruder enabled?""" - ## List of colours to display if there is no material or the material has no known - # colour. defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"] + """List of colours to display if there is no material or the material has no known colour. """ - ## Initialises the extruders model, defining the roles and listening for - # changes in the data. - # - # \param parent Parent QtObject of this list. def __init__(self, parent = None): + """Initialises the extruders model, defining the roles and listening for changes in the data. + + :param parent: Parent QtObject of this list. + """ + super().__init__(parent) self.addRoleName(self.IdRole, "id") @@ -101,14 +101,15 @@ class ExtrudersModel(ListModel): def addOptionalExtruder(self): return self._add_optional_extruder - ## Links to the stack-changed signal of the new extruders when an extruder - # is swapped out or added in the current machine. - # - # \param machine_id The machine for which the extruders changed. This is - # filled by the ExtruderManager.extrudersChanged signal when coming from - # that signal. Application.globalContainerStackChanged doesn't fill this - # signal; it's assumed to be the current printer in that case. def _extrudersChanged(self, machine_id = None): + """Links to the stack-changed signal of the new extruders when an extruder is swapped out or added in the + current machine. + + :param machine_id: The machine for which the extruders changed. This is filled by the + ExtruderManager.extrudersChanged signal when coming from that signal. Application.globalContainerStackChanged + doesn't fill this signal; it's assumed to be the current printer in that case. + """ + machine_manager = Application.getInstance().getMachineManager() if machine_id is not None: if machine_manager.activeMachine is None: @@ -146,11 +147,13 @@ class ExtrudersModel(ListModel): def _updateExtruders(self): self._update_extruder_timer.start() - ## Update the list of extruders. - # - # This should be called whenever the list of extruders changes. @UM.FlameProfiler.profile def __updateExtruders(self): + """Update the list of extruders. + + This should be called whenever the list of extruders changes. + """ + extruders_changed = False if self.count != 0: diff --git a/cura/Machines/Models/FavoriteMaterialsModel.py b/cura/Machines/Models/FavoriteMaterialsModel.py index 6b8f0e8e56..203888d6fb 100644 --- a/cura/Machines/Models/FavoriteMaterialsModel.py +++ b/cura/Machines/Models/FavoriteMaterialsModel.py @@ -4,16 +4,17 @@ from cura.Machines.Models.BaseMaterialsModel import BaseMaterialsModel import cura.CuraApplication # To listen to changes to the preferences. -## Model that shows the list of favorite materials. class FavoriteMaterialsModel(BaseMaterialsModel): + """Model that shows the list of favorite materials.""" + def __init__(self, parent = None): super().__init__(parent) cura.CuraApplication.CuraApplication.getInstance().getPreferences().preferenceChanged.connect(self._onFavoritesChanged) self._onChanged() - ## Triggered when any preference changes, but only handles it when the list - # of favourites is changed. def _onFavoritesChanged(self, preference_key: str) -> None: + """Triggered when any preference changes, but only handles it when the list of favourites is changed. """ + if preference_key != "cura/favorite_materials": return self._onChanged() diff --git a/cura/Machines/Models/FirstStartMachineActionsModel.py b/cura/Machines/Models/FirstStartMachineActionsModel.py index 92caed7b12..7d83f0bff2 100644 --- a/cura/Machines/Models/FirstStartMachineActionsModel.py +++ b/cura/Machines/Models/FirstStartMachineActionsModel.py @@ -11,13 +11,13 @@ if TYPE_CHECKING: from cura.CuraApplication import CuraApplication -# -# This model holds all first-start machine actions for the currently active machine. It has 2 roles: -# - title : the title/name of the action -# - content : the QObject of the QML content of the action -# - action : the MachineAction object itself -# class FirstStartMachineActionsModel(ListModel): + """This model holds all first-start machine actions for the currently active machine. It has 2 roles: + + - title : the title/name of the action + - content : the QObject of the QML content of the action + - action : the MachineAction object itself + """ TitleRole = Qt.UserRole + 1 ContentRole = Qt.UserRole + 2 @@ -73,9 +73,10 @@ class FirstStartMachineActionsModel(ListModel): self._current_action_index += 1 self.currentActionIndexChanged.emit() - # Resets the current action index to 0 so the wizard panel can show actions from the beginning. @pyqtSlot() def reset(self) -> None: + """Resets the current action index to 0 so the wizard panel can show actions from the beginning.""" + self._current_action_index = 0 self.currentActionIndexChanged.emit() diff --git a/cura/Machines/Models/GlobalStacksModel.py b/cura/Machines/Models/GlobalStacksModel.py index 9db4ffe6db..712597c2e7 100644 --- a/cura/Machines/Models/GlobalStacksModel.py +++ b/cura/Machines/Models/GlobalStacksModel.py @@ -19,6 +19,7 @@ class GlobalStacksModel(ListModel): ConnectionTypeRole = Qt.UserRole + 4 MetaDataRole = Qt.UserRole + 5 DiscoverySourceRole = Qt.UserRole + 6 # For separating local and remote printers in the machine management page + RemovalWarningRole = Qt.UserRole + 7 def __init__(self, parent = None) -> None: super().__init__(parent) @@ -42,8 +43,9 @@ class GlobalStacksModel(ListModel): CuraContainerRegistry.getInstance().containerRemoved.connect(self._onContainerChanged) self._updateDelayed() - ## Handler for container added/removed events from registry def _onContainerChanged(self, container) -> None: + """Handler for container added/removed events from registry""" + # We only need to update when the added / removed container GlobalStack if isinstance(container, GlobalStack): self._updateDelayed() @@ -65,13 +67,21 @@ class GlobalStacksModel(ListModel): if parseBool(container_stack.getMetaDataEntry("hidden", False)): continue - section_name = "Network enabled printers" if has_remote_connection else "Local printers" + device_name = container_stack.getMetaDataEntry("group_name", container_stack.getName()) + section_name = "Connected printers" if has_remote_connection else "Preset printers" section_name = self._catalog.i18nc("@info:title", section_name) - items.append({"name": container_stack.getMetaDataEntry("group_name", container_stack.getName()), + default_removal_warning = self._catalog.i18nc( + "@label {0} is the name of a printer that's about to be deleted.", + "Are you sure you wish to remove {0}? This cannot be undone!", device_name + ) + removal_warning = container_stack.getMetaDataEntry("removal_warning", default_removal_warning) + + items.append({"name": device_name, "id": container_stack.getId(), "hasRemoteConnection": has_remote_connection, "metadata": container_stack.getMetaData().copy(), - "discoverySource": section_name}) - items.sort(key = lambda i: (not i["hasRemoteConnection"], i["name"])) + "discoverySource": section_name, + "removalWarning": removal_warning}) + items.sort(key=lambda i: (not i["hasRemoteConnection"], i["name"])) self.setItems(items) diff --git a/cura/Machines/Models/IntentCategoryModel.py b/cura/Machines/Models/IntentCategoryModel.py index 202d79bb15..09a71b8ed6 100644 --- a/cura/Machines/Models/IntentCategoryModel.py +++ b/cura/Machines/Models/IntentCategoryModel.py @@ -4,13 +4,12 @@ import collections from PyQt5.QtCore import Qt, QTimer from typing import TYPE_CHECKING, Optional, Dict -from cura.Machines.Models.IntentTranslations import intent_translations from cura.Machines.Models.IntentModel import IntentModel from cura.Settings.IntentManager import IntentManager from UM.Qt.ListModel import ListModel from UM.Settings.ContainerRegistry import ContainerRegistry #To update the list if anything changes. -from PyQt5.QtCore import pyqtProperty, pyqtSignal +from PyQt5.QtCore import pyqtSignal import cura.CuraApplication if TYPE_CHECKING: from UM.Settings.ContainerRegistry import ContainerInterface @@ -19,9 +18,9 @@ from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") -## Lists the intent categories that are available for the current printer -# configuration. class IntentCategoryModel(ListModel): + """Lists the intent categories that are available for the current printer configuration. """ + NameRole = Qt.UserRole + 1 IntentCategoryRole = Qt.UserRole + 2 WeightRole = Qt.UserRole + 3 @@ -32,10 +31,12 @@ class IntentCategoryModel(ListModel): _translations = collections.OrderedDict() # type: "collections.OrderedDict[str,Dict[str,Optional[str]]]" - # Translations to user-visible string. Ordered by weight. - # TODO: Create a solution for this name and weight to be used dynamically. @classmethod def _get_translations(cls): + """Translations to user-visible string. Ordered by weight. + + TODO: Create a solution for this name and weight to be used dynamically. + """ if len(cls._translations) == 0: cls._translations["default"] = { "name": catalog.i18nc("@label", "Default") @@ -54,9 +55,12 @@ class IntentCategoryModel(ListModel): } return cls._translations - ## Creates a new model for a certain intent category. - # \param The category to list the intent profiles for. def __init__(self, intent_category: str) -> None: + """Creates a new model for a certain intent category. + + :param intent_category: category to list the intent profiles for. + """ + super().__init__() self._intent_category = intent_category @@ -85,16 +89,18 @@ class IntentCategoryModel(ListModel): self.update() - ## Updates the list of intents if an intent profile was added or removed. def _onContainerChange(self, container: "ContainerInterface") -> None: + """Updates the list of intents if an intent profile was added or removed.""" + if container.getMetaDataEntry("type") == "intent": self.update() def update(self): self._update_timer.start() - ## Updates the list of intents. def _update(self) -> None: + """Updates the list of intents.""" + available_categories = IntentManager.getInstance().currentAvailableIntentCategories() result = [] for category in available_categories: @@ -110,9 +116,9 @@ class IntentCategoryModel(ListModel): result.sort(key = lambda k: k["weight"]) self.setItems(result) - ## Get a display value for a category. - ## for categories and keys @staticmethod def translation(category: str, key: str, default: Optional[str] = None): + """Get a display value for a category.for categories and keys""" + display_strings = IntentCategoryModel._get_translations().get(category, {}) return display_strings.get(key, default) diff --git a/cura/Machines/Models/IntentModel.py b/cura/Machines/Models/IntentModel.py index 951be7ab2d..0ec7e268f0 100644 --- a/cura/Machines/Models/IntentModel.py +++ b/cura/Machines/Models/IntentModel.py @@ -98,8 +98,9 @@ class IntentModel(ListModel): new_items = sorted(new_items, key = lambda x: x["layer_height"]) self.setItems(new_items) - ## Get the active materials for all extruders. No duplicates will be returned def _getActiveMaterials(self) -> Set["MaterialNode"]: + """Get the active materials for all extruders. No duplicates will be returned""" + global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() if global_stack is None: return set() diff --git a/cura/Machines/Models/MaterialManagementModel.py b/cura/Machines/Models/MaterialManagementModel.py index f00b81e987..4a696ec974 100644 --- a/cura/Machines/Models/MaterialManagementModel.py +++ b/cura/Machines/Models/MaterialManagementModel.py @@ -19,28 +19,31 @@ if TYPE_CHECKING: catalog = i18nCatalog("cura") -## Proxy class to the materials page in the preferences. -# -# This class handles the actions in that page, such as creating new materials, -# renaming them, etc. class MaterialManagementModel(QObject): - ## Triggered when a favorite is added or removed. - # \param The base file of the material is provided as parameter when this - # emits. + """Proxy class to the materials page in the preferences. + + This class handles the actions in that page, such as creating new materials, renaming them, etc. + """ + favoritesChanged = pyqtSignal(str) + """Triggered when a favorite is added or removed. + + :param The base file of the material is provided as parameter when this emits + """ - ## Can a certain material be deleted, or is it still in use in one of the - # container stacks anywhere? - # - # We forbid the user from deleting a material if it's in use in any stack. - # Deleting it while it's in use can lead to corrupted stacks. In the - # future we might enable this functionality again (deleting the material - # from those stacks) but for now it is easier to prevent the user from - # doing this. - # \param material_node The ContainerTree node of the material to check. - # \return Whether or not the material can be removed. @pyqtSlot("QVariant", result = bool) def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool: + """Can a certain material be deleted, or is it still in use in one of the container stacks anywhere? + + We forbid the user from deleting a material if it's in use in any stack. Deleting it while it's in use can + lead to corrupted stacks. In the future we might enable this functionality again (deleting the material from + those stacks) but for now it is easier to prevent the user from doing this. + + :param material_node: The ContainerTree node of the material to check. + + :return: Whether or not the material can be removed. + """ + container_registry = CuraContainerRegistry.getInstance() ids_to_remove = {metadata.get("id", "") for metadata in container_registry.findInstanceContainersMetadata(base_file = material_node.base_file)} for extruder_stack in container_registry.findContainerStacks(type = "extruder_train"): @@ -48,11 +51,14 @@ class MaterialManagementModel(QObject): return False return True - ## Change the user-visible name of a material. - # \param material_node The ContainerTree node of the material to rename. - # \param name The new name for the material. @pyqtSlot("QVariant", str) def setMaterialName(self, material_node: "MaterialNode", name: str) -> None: + """Change the user-visible name of a material. + + :param material_node: The ContainerTree node of the material to rename. + :param name: The new name for the material. + """ + container_registry = CuraContainerRegistry.getInstance() root_material_id = material_node.base_file if container_registry.isReadOnly(root_material_id): @@ -60,18 +66,20 @@ class MaterialManagementModel(QObject): return return container_registry.findContainers(id = root_material_id)[0].setName(name) - ## Deletes a material from Cura. - # - # This function does not do any safety checking any more. Please call this - # function only if: - # - The material is not read-only. - # - The material is not used in any stacks. - # If the material was not lazy-loaded yet, this will fully load the - # container. When removing this material node, all other materials with - # the same base fill will also be removed. - # \param material_node The material to remove. @pyqtSlot("QVariant") def removeMaterial(self, material_node: "MaterialNode") -> None: + """Deletes a material from Cura. + + This function does not do any safety checking any more. Please call this function only if: + - The material is not read-only. + - The material is not used in any stacks. + + If the material was not lazy-loaded yet, this will fully load the container. When removing this material + node, all other materials with the same base fill will also be removed. + + :param material_node: The material to remove. + """ + container_registry = CuraContainerRegistry.getInstance() materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file) @@ -89,17 +97,19 @@ class MaterialManagementModel(QObject): for material_metadata in materials_this_base_file: container_registry.removeContainer(material_metadata["id"]) - ## Creates a duplicate of a material with the same GUID and base_file - # metadata. - # \param base_file: The base file of the material to duplicate. - # \param new_base_id A new material ID for the base material. The IDs of - # the submaterials will be based off this one. If not provided, a material - # ID will be generated automatically. - # \param new_metadata Metadata for the new material. If not provided, this - # will be duplicated from the original material. - # \return The root material ID of the duplicate material. def duplicateMaterialByBaseFile(self, base_file: str, new_base_id: Optional[str] = None, new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]: + """Creates a duplicate of a material with the same GUID and base_file metadata + + :param base_file: The base file of the material to duplicate. + :param new_base_id: A new material ID for the base material. The IDs of the submaterials will be based off this + one. If not provided, a material ID will be generated automatically. + :param new_metadata: Metadata for the new material. If not provided, this will be duplicated from the original + material. + + :return: The root material ID of the duplicate material. + """ + container_registry = CuraContainerRegistry.getInstance() root_materials = container_registry.findContainers(id = base_file) @@ -171,29 +181,32 @@ class MaterialManagementModel(QObject): return new_base_id - ## Creates a duplicate of a material with the same GUID and base_file - # metadata. - # \param material_node The node representing the material to duplicate. - # \param new_base_id A new material ID for the base material. The IDs of - # the submaterials will be based off this one. If not provided, a material - # ID will be generated automatically. - # \param new_metadata Metadata for the new material. If not provided, this - # will be duplicated from the original material. - # \return The root material ID of the duplicate material. @pyqtSlot("QVariant", result = str) def duplicateMaterial(self, material_node: "MaterialNode", new_base_id: Optional[str] = None, new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]: + """Creates a duplicate of a material with the same GUID and base_file metadata + + :param material_node: The node representing the material to duplicate. + :param new_base_id: A new material ID for the base material. The IDs of the submaterials will be based off this + one. If not provided, a material ID will be generated automatically. + :param new_metadata: Metadata for the new material. If not provided, this will be duplicated from the original + material. + + :return: The root material ID of the duplicate material. + """ return self.duplicateMaterialByBaseFile(material_node.base_file, new_base_id, new_metadata) - ## Create a new material by cloning the preferred material for the current - # material diameter and generate a new GUID. - # - # The material type is explicitly left to be the one from the preferred - # material, since this allows the user to still have SOME profiles to work - # with. - # \return The ID of the newly created material. @pyqtSlot(result = str) def createMaterial(self) -> str: + """Create a new material by cloning the preferred material for the current material diameter and generate a new + GUID. + + The material type is explicitly left to be the one from the preferred material, since this allows the user to + still have SOME profiles to work with. + + :return: The ID of the newly created material. + """ + # Ensure all settings are saved. application = cura.CuraApplication.CuraApplication.getInstance() application.saveSettings() @@ -218,10 +231,13 @@ class MaterialManagementModel(QObject): self.duplicateMaterial(preferred_material_node, new_base_id = new_id, new_metadata = new_metadata) return new_id - ## Adds a certain material to the favorite materials. - # \param material_base_file The base file of the material to add. @pyqtSlot(str) def addFavorite(self, material_base_file: str) -> None: + """Adds a certain material to the favorite materials. + + :param material_base_file: The base file of the material to add. + """ + application = cura.CuraApplication.CuraApplication.getInstance() favorites = application.getPreferences().getValue("cura/favorite_materials").split(";") if material_base_file not in favorites: @@ -230,11 +246,13 @@ class MaterialManagementModel(QObject): application.saveSettings() self.favoritesChanged.emit(material_base_file) - ## Removes a certain material from the favorite materials. - # - # If the material was not in the favorite materials, nothing happens. @pyqtSlot(str) def removeFavorite(self, material_base_file: str) -> None: + """Removes a certain material from the favorite materials. + + If the material was not in the favorite materials, nothing happens. + """ + application = cura.CuraApplication.CuraApplication.getInstance() favorites = application.getPreferences().getValue("cura/favorite_materials").split(";") try: diff --git a/cura/Machines/Models/MultiBuildPlateModel.py b/cura/Machines/Models/MultiBuildPlateModel.py index add960a545..8e2f086e3b 100644 --- a/cura/Machines/Models/MultiBuildPlateModel.py +++ b/cura/Machines/Models/MultiBuildPlateModel.py @@ -9,11 +9,11 @@ from UM.Scene.Selection import Selection from UM.Qt.ListModel import ListModel -# -# This is the model for multi build plate feature. -# This has nothing to do with the build plate types you can choose on the sidebar for a machine. -# class MultiBuildPlateModel(ListModel): + """This is the model for multi build plate feature. + + This has nothing to do with the build plate types you can choose on the sidebar for a machine. + """ maxBuildPlateChanged = pyqtSignal() activeBuildPlateChanged = pyqtSignal() @@ -39,9 +39,10 @@ class MultiBuildPlateModel(ListModel): self._max_build_plate = max_build_plate self.maxBuildPlateChanged.emit() - ## Return the highest build plate number @pyqtProperty(int, notify = maxBuildPlateChanged) def maxBuildPlate(self): + """Return the highest build plate number""" + return self._max_build_plate def setActiveBuildPlate(self, nr): diff --git a/cura/Machines/Models/QualityManagementModel.py b/cura/Machines/Models/QualityManagementModel.py index 74dc8649d0..df12b16c15 100644 --- a/cura/Machines/Models/QualityManagementModel.py +++ b/cura/Machines/Models/QualityManagementModel.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from typing import Any, cast, Dict, Optional, TYPE_CHECKING @@ -26,10 +26,9 @@ if TYPE_CHECKING: from cura.Settings.GlobalStack import GlobalStack -# -# This the QML model for the quality management page. -# class QualityManagementModel(ListModel): + """This the QML model for the quality management page.""" + NameRole = Qt.UserRole + 1 IsReadOnlyRole = Qt.UserRole + 2 QualityGroupRole = Qt.UserRole + 3 @@ -74,11 +73,13 @@ class QualityManagementModel(ListModel): def _onChange(self) -> None: self._update_timer.start() - ## Deletes a custom profile. It will be gone forever. - # \param quality_changes_group The quality changes group representing the - # profile to delete. @pyqtSlot(QObject) def removeQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup") -> None: + """Deletes a custom profile. It will be gone forever. + + :param quality_changes_group: The quality changes group representing the profile to delete. + """ + Logger.log("i", "Removing quality changes group {group_name}".format(group_name = quality_changes_group.name)) removed_quality_changes_ids = set() container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() @@ -95,16 +96,19 @@ class QualityManagementModel(ListModel): if extruder_stack.qualityChanges.getId() in removed_quality_changes_ids: extruder_stack.qualityChanges = empty_quality_changes_container - ## Rename a custom profile. - # - # Because the names must be unique, the new name may not actually become - # the name that was given. The actual name is returned by this function. - # \param quality_changes_group The custom profile that must be renamed. - # \param new_name The desired name for the profile. - # \return The actual new name of the profile, after making the name - # unique. @pyqtSlot(QObject, str, result = str) def renameQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup", new_name: str) -> str: + """Rename a custom profile. + + Because the names must be unique, the new name may not actually become the name that was given. The actual + name is returned by this function. + + :param quality_changes_group: The custom profile that must be renamed. + :param new_name: The desired name for the profile. + + :return: The actual new name of the profile, after making the name unique. + """ + Logger.log("i", "Renaming QualityChangesGroup {old_name} to {new_name}.".format(old_name = quality_changes_group.name, new_name = new_name)) if new_name == quality_changes_group.name: Logger.log("i", "QualityChangesGroup name {name} unchanged.".format(name = quality_changes_group.name)) @@ -128,7 +132,7 @@ class QualityManagementModel(ListModel): for metadata in quality_changes_group.metadata_per_extruder.values(): extruder_container = cast(InstanceContainer, container_registry.findContainers(id = metadata["id"])[0]) extruder_container.setName(new_name) - global_container = cast(InstanceContainer, container_registry.findContainers(id=quality_changes_group.metadata_for_global["id"])[0]) + global_container = cast(InstanceContainer, container_registry.findContainers(id = quality_changes_group.metadata_for_global["id"])[0]) global_container.setName(new_name) quality_changes_group.name = new_name @@ -138,13 +142,16 @@ class QualityManagementModel(ListModel): return new_name - ## Duplicates a given quality profile OR quality changes profile. - # \param new_name The desired name of the new profile. This will be made - # unique, so it might end up with a different name. - # \param quality_model_item The item of this model to duplicate, as - # dictionary. See the descriptions of the roles of this list model. @pyqtSlot(str, "QVariantMap") def duplicateQualityChanges(self, new_name: str, quality_model_item: Dict[str, Any]) -> None: + """Duplicates a given quality profile OR quality changes profile. + + :param new_name: The desired name of the new profile. This will be made unique, so it might end up with a + different name. + :param quality_model_item: The item of this model to duplicate, as dictionary. See the descriptions of the + roles of this list model. + """ + global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() if not global_stack: Logger.log("i", "No active global stack, cannot duplicate quality (changes) profile.") @@ -157,10 +164,16 @@ class QualityManagementModel(ListModel): quality_group = quality_model_item["quality_group"] quality_changes_group = quality_model_item["quality_changes_group"] if quality_changes_group is None: - # Create global quality changes only. new_quality_changes = self._createQualityChanges(quality_group.quality_type, intent_category, new_name, global_stack, extruder_stack = None) container_registry.addContainer(new_quality_changes) + + for extruder in global_stack.extruderList: + new_extruder_quality_changes = self._createQualityChanges(quality_group.quality_type, intent_category, + new_name, + global_stack, extruder_stack = extruder) + + container_registry.addContainer(new_extruder_quality_changes) else: for metadata in [quality_changes_group.metadata_for_global] + list(quality_changes_group.metadata_per_extruder.values()): containers = container_registry.findContainers(id = metadata["id"]) @@ -170,18 +183,18 @@ class QualityManagementModel(ListModel): new_id = container_registry.uniqueName(container.getId()) container_registry.addContainer(container.duplicate(new_id, new_name)) - ## Create quality changes containers from the user containers in the active - # stacks. - # - # This will go through the global and extruder stacks and create - # quality_changes containers from the user containers in each stack. These - # then replace the quality_changes containers in the stack and clear the - # user settings. - # \param base_name The new name for the quality changes profile. The final - # name of the profile might be different from this, because it needs to be - # made unique. @pyqtSlot(str) def createQualityChanges(self, base_name: str) -> None: + """Create quality changes containers from the user containers in the active stacks. + + This will go through the global and extruder stacks and create quality_changes containers from the user + containers in each stack. These then replace the quality_changes containers in the stack and clear the user + settings. + + :param base_name: The new name for the quality changes profile. The final name of the profile might be + different from this, because it needs to be made unique. + """ + machine_manager = cura.CuraApplication.CuraApplication.getInstance().getMachineManager() global_stack = machine_manager.activeMachine @@ -201,7 +214,7 @@ class QualityManagementModel(ListModel): # Go through the active stacks and create quality_changes containers from the user containers. container_manager = ContainerManager.getInstance() - stack_list = [global_stack] + list(global_stack.extruders.values()) + stack_list = [global_stack] + global_stack.extruderList for stack in stack_list: quality_container = stack.quality quality_changes_container = stack.qualityChanges @@ -220,14 +233,16 @@ class QualityManagementModel(ListModel): container_registry.addContainer(new_changes) - ## Create a quality changes container with the given set-up. - # \param quality_type The quality type of the new container. - # \param intent_category The intent category of the new container. - # \param new_name The name of the container. This name must be unique. - # \param machine The global stack to create the profile for. - # \param extruder_stack The extruder stack to create the profile for. If - # not provided, only a global container will be created. def _createQualityChanges(self, quality_type: str, intent_category: Optional[str], new_name: str, machine: "GlobalStack", extruder_stack: Optional["ExtruderStack"]) -> "InstanceContainer": + """Create a quality changes container with the given set-up. + + :param quality_type: The quality type of the new container. + :param intent_category: The intent category of the new container. + :param new_name: The name of the container. This name must be unique. + :param machine: The global stack to create the profile for. + :param extruder_stack: The extruder stack to create the profile for. If not provided, only a global container will be created. + """ + container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() base_id = machine.definition.getId() if extruder_stack is None else extruder_stack.getId() new_id = base_id + "_" + new_name @@ -253,11 +268,13 @@ class QualityManagementModel(ListModel): quality_changes.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.getInstance().SettingVersion) return quality_changes - ## Triggered when any container changed. - # - # This filters the updates to the container manager: When it applies to - # the list of quality changes, we need to update our list. def _qualityChangesListChanged(self, container: "ContainerInterface") -> None: + """Triggered when any container changed. + + This filters the updates to the container manager: When it applies to the list of quality changes, we need to + update our list. + """ + if container.getMetaDataEntry("type") == "quality_changes": self._update() @@ -322,6 +339,7 @@ class QualityManagementModel(ListModel): "layer_height": layer_height, # layer_height is only used for sorting } item_list.append(item) + # Sort by layer_height for built-in qualities item_list = sorted(item_list, key = lambda x: x["layer_height"]) @@ -330,6 +348,9 @@ class QualityManagementModel(ListModel): available_intent_list = [i for i in available_intent_list if i[0] != "default"] result = [] for intent_category, quality_type in available_intent_list: + if not quality_group_dict[quality_type].is_available: + continue + result.append({ "name": quality_group_dict[quality_type].name, # Use the quality name as the display name "is_read_only": True, @@ -350,6 +371,9 @@ class QualityManagementModel(ListModel): # CURA-6913 Note that custom qualities can be based on "not supported", so the quality group can be None. quality_group = quality_group_dict.get(quality_changes_group.quality_type) quality_type = quality_changes_group.quality_type + + if not quality_changes_group.is_available: + continue item = {"name": quality_changes_group.name, "is_read_only": False, "quality_group": quality_group, @@ -366,18 +390,19 @@ class QualityManagementModel(ListModel): self.setItems(item_list) - # TODO: Duplicated code here from InstanceContainersModel. Refactor and remove this later. - # - ## Gets a list of the possible file filters that the plugins have - # registered they can read or write. The convenience meta-filters - # "All Supported Types" and "All Files" are added when listing - # readers, but not when listing writers. - # - # \param io_type \type{str} name of the needed IO type - # \return A list of strings indicating file name filters for a file - # dialog. @pyqtSlot(str, result = "QVariantList") def getFileNameFilters(self, io_type): + """Gets a list of the possible file filters that the plugins have registered they can read or write. + + The convenience meta-filters "All Supported Types" and "All Files" are added when listing readers, + but not when listing writers. + + :param io_type: name of the needed IO type + :return: A list of strings indicating file name filters for a file dialog. + + TODO: Duplicated code here from InstanceContainersModel. Refactor and remove this later. + """ + from UM.i18n import i18nCatalog catalog = i18nCatalog("uranium") #TODO: This function should be in UM.Resources! @@ -394,9 +419,11 @@ class QualityManagementModel(ListModel): filters.append(catalog.i18nc("@item:inlistbox", "All Files (*)")) # Also allow arbitrary files, if the user so prefers. return filters - ## Gets a list of profile reader or writer plugins - # \return List of tuples of (plugin_id, meta_data). def _getIOPlugins(self, io_type): + """Gets a list of profile reader or writer plugins + + :return: List of tuples of (plugin_id, meta_data). + """ from UM.PluginRegistry import PluginRegistry pr = PluginRegistry.getInstance() active_plugin_ids = pr.getActivePlugins() diff --git a/cura/Machines/Models/QualityProfilesDropDownMenuModel.py b/cura/Machines/Models/QualityProfilesDropDownMenuModel.py index 3a79ceeaf1..7aa30c6f82 100644 --- a/cura/Machines/Models/QualityProfilesDropDownMenuModel.py +++ b/cura/Machines/Models/QualityProfilesDropDownMenuModel.py @@ -10,10 +10,9 @@ from cura.Machines.ContainerTree import ContainerTree from cura.Machines.Models.MachineModelUtils import fetchLayerHeight -# -# QML Model for all built-in quality profiles. This model is used for the drop-down quality menu. -# class QualityProfilesDropDownMenuModel(ListModel): + """QML Model for all built-in quality profiles. This model is used for the drop-down quality menu.""" + NameRole = Qt.UserRole + 1 QualityTypeRole = Qt.UserRole + 2 LayerHeightRole = Qt.UserRole + 3 diff --git a/cura/Machines/Models/QualitySettingsModel.py b/cura/Machines/Models/QualitySettingsModel.py index 6835ffb68f..43f5c71e15 100644 --- a/cura/Machines/Models/QualitySettingsModel.py +++ b/cura/Machines/Models/QualitySettingsModel.py @@ -1,18 +1,21 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from PyQt5.QtCore import pyqtProperty, pyqtSignal, Qt +from typing import Set import cura.CuraApplication +from UM import i18nCatalog from UM.Logger import Logger from UM.Qt.ListModel import ListModel from UM.Settings.ContainerRegistry import ContainerRegistry +import os + -# -# This model is used to show details settings of the selected quality in the quality management page. -# class QualitySettingsModel(ListModel): + """This model is used to show details settings of the selected quality in the quality management page.""" + KeyRole = Qt.UserRole + 1 LabelRole = Qt.UserRole + 2 UnitRole = Qt.UserRole + 3 @@ -23,7 +26,7 @@ class QualitySettingsModel(ListModel): GLOBAL_STACK_POSITION = -1 - def __init__(self, parent = None): + def __init__(self, parent = None) -> None: super().__init__(parent = parent) self.addRoleName(self.KeyRole, "key") @@ -38,7 +41,9 @@ class QualitySettingsModel(ListModel): self._application = cura.CuraApplication.CuraApplication.getInstance() self._application.getMachineManager().activeStackChanged.connect(self._update) - self._selected_position = self.GLOBAL_STACK_POSITION #Must be either GLOBAL_STACK_POSITION or an extruder position (0, 1, etc.) + # Must be either GLOBAL_STACK_POSITION or an extruder position (0, 1, etc.) + self._selected_position = self.GLOBAL_STACK_POSITION + self._selected_quality_item = None # The selected quality in the quality management page self._i18n_catalog = None @@ -47,14 +52,14 @@ class QualitySettingsModel(ListModel): selectedPositionChanged = pyqtSignal() selectedQualityItemChanged = pyqtSignal() - def setSelectedPosition(self, selected_position): + def setSelectedPosition(self, selected_position: int) -> None: if selected_position != self._selected_position: self._selected_position = selected_position self.selectedPositionChanged.emit() self._update() @pyqtProperty(int, fset = setSelectedPosition, notify = selectedPositionChanged) - def selectedPosition(self): + def selectedPosition(self) -> int: return self._selected_position def setSelectedQualityItem(self, selected_quality_item): @@ -67,7 +72,7 @@ class QualitySettingsModel(ListModel): def selectedQualityItem(self): return self._selected_quality_item - def _update(self): + def _update(self) -> None: Logger.log("d", "Updating {model_class_name}.".format(model_class_name = self.__class__.__name__)) if not self._selected_quality_item: @@ -79,11 +84,17 @@ class QualitySettingsModel(ListModel): global_container_stack = self._application.getGlobalContainerStack() definition_container = global_container_stack.definition + # Try and find a translation catalog for the definition + for file_name in definition_container.getInheritedFiles(): + catalog = i18nCatalog(os.path.basename(file_name)) + if catalog.hasTranslationLoaded(): + self._i18n_catalog = catalog + quality_group = self._selected_quality_item["quality_group"] quality_changes_group = self._selected_quality_item["quality_changes_group"] quality_node = None - settings_keys = set() + settings_keys = set() # type: Set[str] if quality_group: if self._selected_position == self.GLOBAL_STACK_POSITION: quality_node = quality_group.node_for_global @@ -98,7 +109,8 @@ class QualitySettingsModel(ListModel): # the settings in that quality_changes_group. if quality_changes_group is not None: container_registry = ContainerRegistry.getInstance() - global_containers = container_registry.findContainers(id = quality_changes_group.metadata_for_global["id"]) + metadata_for_global = quality_changes_group.metadata_for_global + global_containers = container_registry.findContainers(id = metadata_for_global["id"]) global_container = None if len(global_containers) == 0 else global_containers[0] extruders_containers = {pos: container_registry.findContainers(id = quality_changes_group.metadata_per_extruder[pos]["id"]) for pos in quality_changes_group.metadata_per_extruder} extruders_container = {pos: None if not containers else containers[0] for pos, containers in extruders_containers.items()} @@ -149,7 +161,7 @@ class QualitySettingsModel(ListModel): if self._selected_position == self.GLOBAL_STACK_POSITION: user_value = global_container_stack.userChanges.getProperty(definition.key, "value") else: - extruder_stack = global_container_stack.extruders[str(self._selected_position)] + extruder_stack = global_container_stack.extruderList[self._selected_position] user_value = extruder_stack.userChanges.getProperty(definition.key, "value") if profile_value is None and user_value is None: diff --git a/cura/Machines/Models/UserChangesModel.py b/cura/Machines/Models/UserChangesModel.py index ec623f0f38..43bbe8a663 100644 --- a/cura/Machines/Models/UserChangesModel.py +++ b/cura/Machines/Models/UserChangesModel.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import os @@ -7,6 +7,7 @@ from collections import OrderedDict from PyQt5.QtCore import pyqtSlot, Qt from UM.Application import Application +from UM.Logger import Logger from UM.Settings.ContainerRegistry import ContainerRegistry from UM.i18n import i18nCatalog from UM.Settings.SettingFunction import SettingFunction @@ -83,14 +84,18 @@ class UserChangesModel(ListModel): # Find the category of the instance by moving up until we find a category. category = user_changes.getInstance(setting_key).definition - while category.type != "category": + while category is not None and category.type != "category": category = category.parent # Handle translation (and fallback if we weren't able to find any translation files. - if self._i18n_catalog: - category_label = self._i18n_catalog.i18nc(category.key + " label", category.label) - else: - category_label = category.label + if category is not None: + if self._i18n_catalog: + category_label = self._i18n_catalog.i18nc(category.key + " label", category.label) + else: + category_label = category.label + else: # Setting is not in any category. Shouldn't happen, but it do. See https://sentry.io/share/issue/d735884370154166bc846904d9b812ff/ + Logger.error("Setting {key} is not in any setting category.".format(key = setting_key)) + category_label = "" if self._i18n_catalog: label = self._i18n_catalog.i18nc(setting_key + " label", stack.getProperty(setting_key, "label")) diff --git a/cura/Machines/QualityChangesGroup.py b/cura/Machines/QualityChangesGroup.py index 655060070b..668fff785a 100644 --- a/cura/Machines/QualityChangesGroup.py +++ b/cura/Machines/QualityChangesGroup.py @@ -6,12 +6,12 @@ from typing import Any, Dict, Optional from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal -## Data struct to group several quality changes instance containers together. -# -# Each group represents one "custom profile" as the user sees it, which -# contains an instance container for the global stack and one instance -# container per extruder. class QualityChangesGroup(QObject): + """Data struct to group several quality changes instance containers together. + + Each group represents one "custom profile" as the user sees it, which contains an instance container for the + global stack and one instance container per extruder. + """ def __init__(self, name: str, quality_type: str, intent_category: str, parent: Optional["QObject"] = None) -> None: super().__init__(parent) diff --git a/cura/Machines/QualityGroup.py b/cura/Machines/QualityGroup.py index 58ba3acc63..2e5e8db905 100644 --- a/cura/Machines/QualityGroup.py +++ b/cura/Machines/QualityGroup.py @@ -3,36 +3,40 @@ from typing import Dict, Optional, List, Set -from PyQt5.QtCore import QObject, pyqtSlot - from UM.Logger import Logger from UM.Util import parseBool from cura.Machines.ContainerNode import ContainerNode -## A QualityGroup represents a group of quality containers that must be applied -# to each ContainerStack when it's used. -# -# A concrete example: When there are two extruders and the user selects the -# quality type "normal", this quality type must be applied to all stacks in a -# machine, although each stack can have different containers. So one global -# profile gets put on the global stack and one extruder profile gets put on -# each extruder stack. This quality group then contains the following -# profiles (for instance): -# GlobalStack ExtruderStack 1 ExtruderStack 2 -# quality container: um3_global_normal um3_aa04_pla_normal um3_aa04_abs_normal -# -# The purpose of these quality groups is to group the containers that can be -# applied to a configuration, so that when a quality level is selected, the -# container can directly be applied to each stack instead of looking them up -# again. class QualityGroup: - ## Constructs a new group. - # \param name The user-visible name for the group. - # \param quality_type The quality level that each profile in this group - # has. + """A QualityGroup represents a group of quality containers that must be applied to each ContainerStack when it's + used. + + A concrete example: When there are two extruders and the user selects the quality type "normal", this quality + type must be applied to all stacks in a machine, although each stack can have different containers. So one global + profile gets put on the global stack and one extruder profile gets put on each extruder stack. This quality group + then contains the following profiles (for instance): + - GlobalStack + - ExtruderStack 1 + - ExtruderStack 2 + quality container: + - um3_global_normal + - um3_aa04_pla_normal + - um3_aa04_abs_normal + + The purpose of these quality groups is to group the containers that can be applied to a configuration, + so that when a quality level is selected, the container can directly be applied to each stack instead of looking + them up again. + """ + def __init__(self, name: str, quality_type: str) -> None: + """Constructs a new group. + + :param name: The user-visible name for the group. + :param quality_type: The quality level that each profile in this group has. + """ + self.name = name self.node_for_global = None # type: Optional[ContainerNode] self.nodes_for_extruders = {} # type: Dict[int, ContainerNode] diff --git a/cura/Machines/QualityNode.py b/cura/Machines/QualityNode.py index 7696dfb117..dcbe486952 100644 --- a/cura/Machines/QualityNode.py +++ b/cura/Machines/QualityNode.py @@ -13,12 +13,14 @@ if TYPE_CHECKING: from cura.Machines.MachineNode import MachineNode -## Represents a quality profile in the container tree. -# -# This may either be a normal quality profile or a global quality profile. -# -# Its subcontainers are intent profiles. class QualityNode(ContainerNode): + """Represents a quality profile in the container tree. + + This may either be a normal quality profile or a global quality profile. + + Its subcontainers are intent profiles. + """ + def __init__(self, container_id: str, parent: Union["MaterialNode", "MachineNode"]) -> None: super().__init__(container_id) self.parent = parent diff --git a/cura/Machines/VariantNode.py b/cura/Machines/VariantNode.py index 0f30782a91..39664946a3 100644 --- a/cura/Machines/VariantNode.py +++ b/cura/Machines/VariantNode.py @@ -17,16 +17,16 @@ if TYPE_CHECKING: from cura.Machines.MachineNode import MachineNode -## This class represents an extruder variant in the container tree. -# -# The subnodes of these nodes are materials. -# -# This node contains materials with ALL filament diameters underneath it. The -# tree of this variant is not specific to one global stack, so because the -# list of materials can be different per stack depending on the compatible -# material diameter setting, we cannot filter them here. Filtering must be -# done in the model. class VariantNode(ContainerNode): + """This class represents an extruder variant in the container tree. + + The subnodes of these nodes are materials. + + This node contains materials with ALL filament diameters underneath it. The tree of this variant is not specific + to one global stack, so because the list of materials can be different per stack depending on the compatible + material diameter setting, we cannot filter them here. Filtering must be done in the model. + """ + def __init__(self, container_id: str, machine: "MachineNode") -> None: super().__init__(container_id) self.machine = machine @@ -39,9 +39,10 @@ class VariantNode(ContainerNode): container_registry.containerRemoved.connect(self._materialRemoved) self._loadAll() - ## (Re)loads all materials under this variant. @UM.FlameProfiler.profile def _loadAll(self) -> None: + """(Re)loads all materials under this variant.""" + container_registry = ContainerRegistry.getInstance() if not self.machine.has_materials: @@ -69,29 +70,29 @@ class VariantNode(ContainerNode): if not self.materials: self.materials["empty_material"] = MaterialNode("empty_material", variant = self) - ## Finds the preferred material for this printer with this nozzle in one of - # the extruders. - # - # If the preferred material is not available, an arbitrary material is - # returned. If there is a configuration mistake (like a typo in the - # preferred material) this returns a random available material. If there - # are no available materials, this will return the empty material node. - # \param approximate_diameter The desired approximate diameter of the - # material. - # \return The node for the preferred material, or any arbitrary material - # if there is no match. def preferredMaterial(self, approximate_diameter: int) -> MaterialNode: + """Finds the preferred material for this printer with this nozzle in one of the extruders. + + If the preferred material is not available, an arbitrary material is returned. If there is a configuration + mistake (like a typo in the preferred material) this returns a random available material. If there are no + available materials, this will return the empty material node. + + :param approximate_diameter: The desired approximate diameter of the material. + + :return: The node for the preferred material, or any arbitrary material if there is no match. + """ + for base_material, material_node in self.materials.items(): if self.machine.preferred_material == base_material and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")): return material_node - + # First fallback: Check if we should be checking for the 175 variant. if approximate_diameter == 2: preferred_material = self.machine.preferred_material + "_175" for base_material, material_node in self.materials.items(): if preferred_material == base_material and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")): return material_node - + # Second fallback: Choose any material with matching diameter. for material_node in self.materials.values(): if material_node.getMetaDataEntry("approximate_diameter") and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")): @@ -107,10 +108,10 @@ class VariantNode(ContainerNode): )) return fallback - ## When a material gets added to the set of profiles, we need to update our - # tree here. @UM.FlameProfiler.profile def _materialAdded(self, container: ContainerInterface) -> None: + """When a material gets added to the set of profiles, we need to update our tree here.""" + if container.getMetaDataEntry("type") != "material": return # Not interested. if not ContainerRegistry.getInstance().findContainersMetadata(id = container.getId()): diff --git a/cura/MultiplyObjectsJob.py b/cura/MultiplyObjectsJob.py index 134e579746..1ba78edacf 100644 --- a/cura/MultiplyObjectsJob.py +++ b/cura/MultiplyObjectsJob.py @@ -4,18 +4,15 @@ import copy from typing import List +from UM.Application import Application from UM.Job import Job -from UM.Operations.GroupedOperation import GroupedOperation from UM.Message import Message +from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Scene.SceneNode import SceneNode from UM.i18n import i18nCatalog -i18n_catalog = i18nCatalog("cura") - -from cura.Arranging.Arrange import Arrange -from cura.Arranging.ShapeArray import ShapeArray +from cura.Arranging.Nest2DArrange import arrange -from UM.Application import Application -from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation +i18n_catalog = i18nCatalog("cura") class MultiplyObjectsJob(Job): @@ -26,28 +23,27 @@ class MultiplyObjectsJob(Job): self._min_offset = min_offset def run(self) -> None: - status_message = Message(i18n_catalog.i18nc("@info:status", "Multiplying and placing objects"), lifetime=0, - dismissable=False, progress=0, title = i18n_catalog.i18nc("@info:title", "Placing Objects")) + status_message = Message(i18n_catalog.i18nc("@info:status", "Multiplying and placing objects"), lifetime = 0, + dismissable = False, progress = 0, + title = i18n_catalog.i18nc("@info:title", "Placing Objects")) status_message.show() scene = Application.getInstance().getController().getScene() - total_progress = len(self._objects) * self._count - current_progress = 0 - global_container_stack = Application.getInstance().getGlobalContainerStack() if global_container_stack is None: return # We can't do anything in this case. - machine_width = global_container_stack.getProperty("machine_width", "value") - machine_depth = global_container_stack.getProperty("machine_depth", "value") root = scene.getRoot() - scale = 0.5 - arranger = Arrange.create(x = machine_width, y = machine_depth, scene_root = root, scale = scale, min_offset = self._min_offset) + processed_nodes = [] # type: List[SceneNode] nodes = [] - not_fit_count = 0 - found_solution_for_all = False + fixed_nodes = [] + for node_ in DepthFirstIterator(root): + # Only count sliceable objects + if node_.callDecoration("isSliceable"): + fixed_nodes.append(node_) + for node in self._objects: # If object is part of a group, multiply group current_node = node @@ -58,31 +54,8 @@ class MultiplyObjectsJob(Job): continue processed_nodes.append(current_node) - node_too_big = False - if node.getBoundingBox().width < machine_width or node.getBoundingBox().depth < machine_depth: - offset_shape_arr, hull_shape_arr = ShapeArray.fromNode(current_node, min_offset = self._min_offset, scale = scale) - else: - node_too_big = True - - found_solution_for_all = True - arranger.resetLastPriority() for _ in range(self._count): - # We do place the nodes one by one, as we want to yield in between. new_node = copy.deepcopy(node) - solution_found = False - if not node_too_big: - if offset_shape_arr is not None and hull_shape_arr is not None: - solution_found = arranger.findNodePlacement(new_node, offset_shape_arr, hull_shape_arr) - else: - # The node has no shape, so no need to arrange it. The solution is simple: Do nothing. - solution_found = True - - if node_too_big or not solution_found: - found_solution_for_all = False - new_location = new_node.getPosition() - new_location = new_location.set(z = - not_fit_count * 20) - new_node.setPosition(new_location) - not_fit_count += 1 # Same build plate build_plate_number = current_node.callDecoration("getBuildPlateNumber") @@ -91,19 +64,15 @@ class MultiplyObjectsJob(Job): child.callDecoration("setBuildPlateNumber", build_plate_number) nodes.append(new_node) - current_progress += 1 - status_message.setProgress((current_progress / total_progress) * 100) - Job.yieldThread() - - Job.yieldThread() + found_solution_for_all = True if nodes: - operation = GroupedOperation() - for new_node in nodes: - operation.addOperation(AddSceneNodeOperation(new_node, current_node.getParent())) - operation.push() + found_solution_for_all = arrange(nodes, Application.getInstance().getBuildVolume(), fixed_nodes, + factor = 10000, add_new_nodes_in_scene = True) status_message.hide() if not found_solution_for_all: - no_full_solution_message = Message(i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"), title = i18n_catalog.i18nc("@info:title", "Placing Object")) + no_full_solution_message = Message( + i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"), + title = i18n_catalog.i18nc("@info:title", "Placing Object")) no_full_solution_message.show() diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py index cc809abf05..f7fe6958a1 100644 --- a/cura/OAuth2/AuthorizationHelpers.py +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -1,11 +1,11 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # 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 typing import Optional +from typing import Optional, Any, Dict, Tuple import requests @@ -16,23 +16,28 @@ from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settin catalog = i18nCatalog("cura") TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S" -## Class containing several helpers to deal with the authorization flow. + class AuthorizationHelpers: + """Class containing several helpers to deal with the authorization flow.""" + def __init__(self, settings: "OAuth2Settings") -> None: self._settings = settings self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL) @property - ## The OAuth2 settings object. def settings(self) -> "OAuth2Settings": + """The OAuth2 settings object.""" + return self._settings - ## Request the access token from the authorization server. - # \param authorization_code: The authorization code from the 1st step. - # \param verification_code: The verification code needed for the PKCE - # extension. - # \return An AuthenticationResponse object. def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str) -> "AuthenticationResponse": + """Request the access token from the authorization server. + + :param authorization_code: The authorization code from the 1st step. + :param verification_code: The verification code needed for the PKCE extension. + :return: An AuthenticationResponse object. + """ + data = { "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 "", @@ -46,10 +51,13 @@ class AuthorizationHelpers: except requests.exceptions.ConnectionError: return AuthenticationResponse(success=False, err_message="Unable to connect to remote server") - ## Request the access token from the authorization server using a refresh token. - # \param refresh_token: - # \return An AuthenticationResponse object. def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse": + """Request the access token from the authorization server using a refresh token. + + :param refresh_token: + :return: An AuthenticationResponse object. + """ + Logger.log("d", "Refreshing the access token.") data = { "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", @@ -64,10 +72,13 @@ class AuthorizationHelpers: return AuthenticationResponse(success=False, err_message="Unable to connect to remote server") @staticmethod - ## Parse the token response from the authorization server into an AuthenticationResponse object. - # \param token_response: The JSON string data response from the authorization server. - # \return An AuthenticationResponse object. def parseTokenResponse(token_response: requests.models.Response) -> "AuthenticationResponse": + """Parse the token response from the authorization server into an AuthenticationResponse object. + + :param token_response: The JSON string data response from the authorization server. + :return: An AuthenticationResponse object. + """ + token_data = None try: @@ -89,10 +100,13 @@ class AuthorizationHelpers: scope=token_data["scope"], received_at=datetime.now().strftime(TOKEN_TIMESTAMP_FORMAT)) - ## Calls the authentication API endpoint to get the token data. - # \param access_token: The encoded JWT token. - # \return Dict containing some profile data. def parseJWT(self, access_token: str) -> Optional["UserProfile"]: + """Calls the authentication API endpoint to get the token data. + + :param access_token: The encoded JWT token. + :return: Dict containing some profile data. + """ + try: token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = { "Authorization": "Bearer {}".format(access_token) @@ -108,23 +122,32 @@ class AuthorizationHelpers: 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", "") + profile_image_url = user_data.get("profile_image_url", ""), + organization_id = user_data.get("organization", {}).get("organization_id"), + subscriptions = user_data.get("subscriptions", []) ) @staticmethod - ## 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 better to - # leave it at 32 def generateVerificationCode(code_length: int = 32) -> str: + """Generate a verification code of arbitrary length. + + :param code_length:: How long should the code be? This should never be lower than 16, but it's probably + better to leave it at 32 + """ + return "".join(random.choice("0123456789ABCDEF") for i in range(code_length)) @staticmethod - ## Generates a base64 encoded sha512 encrypted version of a given string. - # \param verification_code: - # \return The encrypted code in base64 format. def generateVerificationCodeChallenge(verification_code: str) -> str: + """Generates a base64 encoded sha512 encrypted version of a given string. + + :param verification_code: + :return: The encrypted code in base64 format. + """ + encoded = sha512(verification_code.encode()).digest() return b64encode(encoded, altchars = b"_-").decode() diff --git a/cura/OAuth2/AuthorizationRequestHandler.py b/cura/OAuth2/AuthorizationRequestHandler.py index b002039491..c7ce9b6faf 100644 --- a/cura/OAuth2/AuthorizationRequestHandler.py +++ b/cura/OAuth2/AuthorizationRequestHandler.py @@ -14,9 +14,12 @@ if TYPE_CHECKING: catalog = i18nCatalog("cura") -## This handler handles all HTTP requests on the local web server. -# It also requests the access token for the 2nd stage of the OAuth flow. class AuthorizationRequestHandler(BaseHTTPRequestHandler): + """This handler handles all HTTP requests on the local web server. + + It also requests the access token for the 2nd stage of the OAuth flow. + """ + def __init__(self, request, client_address, server) -> None: super().__init__(request, client_address, server) @@ -55,10 +58,13 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): # This will cause the server to shut down, so we do it at the very end of the request handling. self.authorization_callback(token_response) - ## Handler for the callback URL redirect. - # \param query Dict containing the HTTP query parameters. - # \return HTTP ResponseData containing a success page to show to the user. def _handleCallback(self, query: Dict[Any, List]) -> Tuple[ResponseData, Optional[AuthenticationResponse]]: + """Handler for the callback URL redirect. + + :param query: Dict containing the HTTP query parameters. + :return: HTTP ResponseData containing a success page to show to the user. + """ + code = self._queryGet(query, "code") state = self._queryGet(query, "state") if state != self.state: @@ -95,9 +101,10 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): self.authorization_helpers.settings.AUTH_FAILED_REDIRECT ), token_response - ## Handle all other non-existing server calls. @staticmethod def _handleNotFound() -> ResponseData: + """Handle all other non-existing server calls.""" + return ResponseData(status = HTTP_STATUS["NOT_FOUND"], content_type = "text/html", data_stream = b"Not found.") def _sendHeaders(self, status: "ResponseStatus", content_type: str, redirect_uri: str = None) -> None: @@ -110,7 +117,8 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler): def _sendData(self, data: bytes) -> None: self.wfile.write(data) - ## Convenience helper for getting values from a pre-parsed query string @staticmethod def _queryGet(query_data: Dict[Any, List], key: str, default: Optional[str] = None) -> Optional[str]: + """Convenience helper for getting values from a pre-parsed query string""" + return query_data.get(key, [default])[0] diff --git a/cura/OAuth2/AuthorizationRequestServer.py b/cura/OAuth2/AuthorizationRequestServer.py index 687bbf5ad8..4ed3975638 100644 --- a/cura/OAuth2/AuthorizationRequestServer.py +++ b/cura/OAuth2/AuthorizationRequestServer.py @@ -2,6 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. from http.server import HTTPServer +from socketserver import ThreadingMixIn from typing import Callable, Any, TYPE_CHECKING if TYPE_CHECKING: @@ -9,21 +10,26 @@ if TYPE_CHECKING: from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers -## The authorization request callback handler server. -# This subclass is needed to be able to pass some data to the request handler. -# This cannot be done on the request handler directly as the HTTPServer -# creates an instance of the handler after init. -class AuthorizationRequestServer(HTTPServer): - ## Set the authorization helpers instance on the request handler. +class AuthorizationRequestServer(ThreadingMixIn, HTTPServer): + """The authorization request callback handler server. + + This subclass is needed to be able to pass some data to the request handler. This cannot be done on the request + handler directly as the HTTPServer creates an instance of the handler after init. + """ + def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None: + """Set the authorization helpers instance on the request handler.""" + self.RequestHandlerClass.authorization_helpers = authorization_helpers # type: ignore - ## Set the authorization callback on the request handler. def setAuthorizationCallback(self, authorization_callback: Callable[["AuthenticationResponse"], Any]) -> None: + """Set the authorization callback on the request handler.""" + self.RequestHandlerClass.authorization_callback = authorization_callback # type: ignore - ## Set the verification code on the request handler. def setVerificationCode(self, verification_code: str) -> None: + """Set the verification code on the request handler.""" + self.RequestHandlerClass.verification_code = verification_code # type: ignore def setState(self, state: str) -> None: diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index 13e0e50373..9a5c81ae55 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -3,33 +3,34 @@ import json from datetime import datetime, timedelta -from typing import Optional, TYPE_CHECKING -from urllib.parse import urlencode +from typing import Optional, TYPE_CHECKING, Dict +from urllib.parse import urlencode, quote_plus import requests.exceptions - from PyQt5.QtCore import QUrl from PyQt5.QtGui import QDesktopServices from UM.Logger import Logger from UM.Message import Message from UM.Signal import Signal - -from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer +from UM.i18n import i18nCatalog from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT +from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer from cura.OAuth2.Models import AuthenticationResponse -from UM.i18n import i18nCatalog i18n_catalog = i18nCatalog("cura") if TYPE_CHECKING: from cura.OAuth2.Models import UserProfile, OAuth2Settings from UM.Preferences import Preferences +MYCLOUD_LOGOFF_URL = "https://mycloud.ultimaker.com/logoff" -## The authorization service is responsible for handling the login flow, -# storing user credentials and providing account information. class AuthorizationService: + """The authorization service is responsible for handling the login flow, storing user credentials and providing + account information. + """ + # Emit signal when authentication is completed. onAuthStateChanged = Signal() @@ -61,11 +62,16 @@ class AuthorizationService: if self._preferences: self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") - ## 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. - # \return UserProfile if a user is logged in, None otherwise. - # \sa _parseJWT def getUserProfile(self) -> Optional["UserProfile"]: + """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. + + :return: UserProfile if a user is logged in, None otherwise. + + See also: :py:method:`cura.OAuth2.AuthorizationService.AuthorizationService._parseJWT` + """ + if not self._user_profile: # If no user profile was stored locally, we try to get it from JWT. try: @@ -83,9 +89,12 @@ class AuthorizationService: return self._user_profile - ## Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there. - # \return UserProfile if it was able to parse, None otherwise. def _parseJWT(self) -> Optional["UserProfile"]: + """Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there. + + :return: UserProfile if it was able to parse, None otherwise. + """ + if not self._auth_data or self._auth_data.access_token is None: # If no auth data exists, we should always log in again. Logger.log("d", "There was no auth data or access token") @@ -108,8 +117,9 @@ class AuthorizationService: self._storeAuthData(self._auth_data) return self._auth_helpers.parseJWT(self._auth_data.access_token) - ## Get the access token as provided by the repsonse data. def getAccessToken(self) -> Optional[str]: + """Get the access token as provided by the repsonse data.""" + if self._auth_data is None: Logger.log("d", "No auth data to retrieve the access_token from") return None @@ -124,8 +134,9 @@ class AuthorizationService: return self._auth_data.access_token if self._auth_data else None - ## Try to refresh the access token. This should be used when it has expired. def refreshAccessToken(self) -> None: + """Try to refresh the access token. This should be used when it has expired.""" + 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.") return @@ -137,14 +148,16 @@ class AuthorizationService: Logger.log("w", "Failed to get a new access token from the server.") self.onAuthStateChanged.emit(logged_in = False) - ## Delete the authentication data that we have stored locally (eg; logout) def deleteAuthData(self) -> None: + """Delete the authentication data that we have stored locally (eg; logout)""" + if self._auth_data is not None: self._storeAuthData() self.onAuthStateChanged.emit(logged_in = False) - ## Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login. - def startAuthorizationFlow(self) -> None: + def startAuthorizationFlow(self, force_browser_logout: bool = False) -> None: + """Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login.""" + Logger.log("d", "Starting new OAuth2 flow...") # Create the tokens needed for the code challenge (PKCE) extension for OAuth2. @@ -155,8 +168,8 @@ class AuthorizationService: state = AuthorizationHelpers.generateVerificationCode() - # Create the query string needed for the OAuth2 flow. - query_string = urlencode({ + # Create the query dict needed for the OAuth2 flow. + query_parameters_dict = { "client_id": self._settings.CLIENT_ID, "redirect_uri": self._settings.CALLBACK_URL, "scope": self._settings.CLIENT_SCOPES, @@ -164,16 +177,43 @@ class AuthorizationService: "state": state, # Forever in our Hearts, RIP "(.Y.)" (2018-2020) "code_challenge": challenge_code, "code_challenge_method": "S512" - }) - - # Open the authorization page in a new browser window. - QDesktopServices.openUrl(QUrl("{}?{}".format(self._auth_url, query_string))) + } # Start a local web server to receive the callback URL on. - self._server.start(verification_code, state) + try: + self._server.start(verification_code, state) + except OSError: + Logger.logException("w", "Unable to create authorization request server") + Message(i18n_catalog.i18nc("@info", "Unable to start a new sign in process. Check if another sign in attempt is still active."), + title=i18n_catalog.i18nc("@info:title", "Warning")).show() + return + + auth_url = self._generate_auth_url(query_parameters_dict, force_browser_logout) + # Open the authorization page in a new browser window. + QDesktopServices.openUrl(QUrl(auth_url)) + + def _generate_auth_url(self, query_parameters_dict: Dict[str, Optional[str]], force_browser_logout: bool) -> str: + """ + Generates the authentications url based on the original auth_url and the query_parameters_dict to be included. + If there is a request to force logging out of mycloud in the browser, the link to logoff from mycloud is + prepended in order to force the browser to logoff from mycloud and then redirect to the authentication url to + login again. This case is used to sync the accounts between Cura and the browser. + + :param query_parameters_dict: A dictionary with the query parameters to be url encoded and added to the + authentication link + :param force_browser_logout: If True, Cura will prepend the MYCLOUD_LOGOFF_URL link before the authentication + link to force the a browser logout from mycloud.ultimaker.com + :return: The authentication URL, properly formatted and encoded + """ + auth_url = "{}?{}".format(self._auth_url, urlencode(query_parameters_dict)) + if force_browser_logout: + # The url after '?next=' should be urlencoded + auth_url = "{}?next={}".format(MYCLOUD_LOGOFF_URL, quote_plus(auth_url)) + return auth_url - ## Callback method for the authentication flow. def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None: + """Callback method for the authentication flow.""" + if auth_response.success: self._storeAuthData(auth_response) self.onAuthStateChanged.emit(logged_in = True) @@ -181,8 +221,9 @@ class AuthorizationService: self.onAuthenticationError.emit(logged_in = False, error_message = auth_response.err_message) self._server.stop() # Stop the web server at all times. - ## Load authentication data from preferences. def loadAuthDataFromPreferences(self) -> None: + """Load authentication data from preferences.""" + if self._preferences is None: Logger.log("e", "Unable to load authentication data, since no preference has been set!") return @@ -203,13 +244,14 @@ class AuthorizationService: except ValueError: Logger.logException("w", "Could not load auth data from preferences") - ## Store authentication data in preferences. def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None: + """Store authentication data in preferences.""" + Logger.log("d", "Attempting to store the auth data") if self._preferences is None: Logger.log("e", "Unable to save authentication data, since no preference has been set!") return - + self._auth_data = auth_data if auth_data: self._user_profile = self.getUserProfile() diff --git a/cura/OAuth2/LocalAuthorizationServer.py b/cura/OAuth2/LocalAuthorizationServer.py index 0e4e491e46..ac14b00985 100644 --- a/cura/OAuth2/LocalAuthorizationServer.py +++ b/cura/OAuth2/LocalAuthorizationServer.py @@ -1,6 +1,6 @@ # Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. - +import sys import threading from typing import Any, Callable, Optional, TYPE_CHECKING @@ -20,18 +20,23 @@ if TYPE_CHECKING: class LocalAuthorizationServer: - ## The local LocalAuthorizationServer takes care of the oauth2 callbacks. - # Once the flow is completed, this server should be closed down again by - # calling stop() - # \param auth_helpers An instance of the authorization helpers class. - # \param auth_state_changed_callback A callback function to be called when - # the authorization state changes. - # \param daemon Whether the server thread should be run in daemon mode. - # Note: Daemon threads are abruptly stopped at shutdown. Their resources - # (e.g. open files) may never be released. def __init__(self, auth_helpers: "AuthorizationHelpers", auth_state_changed_callback: Callable[["AuthenticationResponse"], Any], daemon: bool) -> None: + """The local LocalAuthorizationServer takes care of the oauth2 callbacks. + + Once the flow is completed, this server should be closed down again by calling + :py:meth:`cura.OAuth2.LocalAuthorizationServer.LocalAuthorizationServer.stop()` + + :param auth_helpers: An instance of the authorization helpers class. + :param auth_state_changed_callback: A callback function to be called when the authorization state changes. + :param daemon: Whether the server thread should be run in daemon mode. + + .. note:: + + Daemon threads are abruptly stopped at shutdown. Their resources (e.g. open files) may never be released. + """ + self._web_server = None # type: Optional[AuthorizationRequestServer] self._web_server_thread = None # type: Optional[threading.Thread] self._web_server_port = auth_helpers.settings.CALLBACK_PORT @@ -39,10 +44,13 @@ class LocalAuthorizationServer: self._auth_state_changed_callback = auth_state_changed_callback self._daemon = daemon - ## Starts the local web server to handle the authorization callback. - # \param verification_code The verification code part of the OAuth2 client identification. - # \param state The unique state code (to ensure that the request we get back is really from the server. def start(self, verification_code: str, state: str) -> None: + """Starts the local web server to handle the authorization callback. + + :param verification_code: The verification code part of the OAuth2 client identification. + :param state: The unique state code (to ensure that the request we get back is really from the server. + """ + if self._web_server: # If the server is already running (because of a previously aborted auth flow), we don't have to start it. # We still inject the new verification code though. @@ -63,18 +71,37 @@ class LocalAuthorizationServer: self._web_server.setState(state) # Start the server on a new thread. - self._web_server_thread = threading.Thread(None, self._web_server.serve_forever, daemon = self._daemon) + self._web_server_thread = threading.Thread(None, self._serve_forever, daemon = self._daemon) self._web_server_thread.start() - ## Stops the web server if it was running. It also does some cleanup. def stop(self) -> None: + """Stops the web server if it was running. It also does some cleanup.""" + Logger.log("d", "Stopping local oauth2 web server...") if self._web_server: try: - self._web_server.server_close() + self._web_server.shutdown() except OSError: # OS error can happen if the socket was already closed. We really don't care about that case. pass self._web_server = None self._web_server_thread = None + + def _serve_forever(self) -> None: + """ + If the platform is windows, this function calls the serve_forever function of the _web_server, catching any + OSErrors that may occur in the thread, thus making the reported message more log-friendly. + If it is any other platform, it just calls the serve_forever function immediately. + + :return: None + """ + if self._web_server: + if sys.platform == "win32": + try: + self._web_server.serve_forever() + except OSError as e: + Logger.warning(str(e)) + else: + # Leave the default behavior in non-windows platforms + self._web_server.serve_forever() diff --git a/cura/OAuth2/Models.py b/cura/OAuth2/Models.py index dd935fef6e..f49fdc1421 100644 --- a/cura/OAuth2/Models.py +++ b/cura/OAuth2/Models.py @@ -1,6 +1,6 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List class BaseModel: @@ -8,8 +8,9 @@ class BaseModel: self.__dict__.update(kwargs) -## OAuth OAuth2Settings data template. class OAuth2Settings(BaseModel): + """OAuth OAuth2Settings data template.""" + CALLBACK_PORT = None # type: Optional[int] OAUTH_SERVER_URL = None # type: Optional[str] CLIENT_ID = None # type: Optional[str] @@ -20,16 +21,20 @@ class OAuth2Settings(BaseModel): AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str -## User profile data template. class UserProfile(BaseModel): + """User profile data template.""" + user_id = None # type: Optional[str] username = None # type: Optional[str] profile_image_url = None # type: Optional[str] + organization_id = None # type: Optional[str] + subscriptions = None # type: Optional[List[Dict[str, Any]]] -## Authentication data template. class AuthenticationResponse(BaseModel): - """Data comes from the token response with success flag and error message added.""" + """Authentication data template.""" + + # Data comes from the token response with success flag and error message added. success = True # type: bool token_type = None # type: Optional[str] access_token = None # type: Optional[str] @@ -40,22 +45,25 @@ class AuthenticationResponse(BaseModel): received_at = None # type: Optional[str] -## Response status template. class ResponseStatus(BaseModel): + """Response status template.""" + code = 200 # type: int message = "" # type: str -## Response data template. class ResponseData(BaseModel): + """Response data template.""" + status = None # type: ResponseStatus data_stream = None # type: Optional[bytes] redirect_uri = None # type: Optional[str] content_type = "text/html" # type: str -## Possible HTTP responses. HTTP_STATUS = { +"""Possible HTTP responses.""" + "OK": ResponseStatus(code = 200, message = "OK"), "NOT_FOUND": ResponseStatus(code = 404, message = "NOT FOUND"), "REDIRECT": ResponseStatus(code = 302, message = "REDIRECT") diff --git a/cura/OneAtATimeIterator.py b/cura/OneAtATimeIterator.py index 3373f2104f..8bdddba554 100644 --- a/cura/OneAtATimeIterator.py +++ b/cura/OneAtATimeIterator.py @@ -7,27 +7,33 @@ from UM.Scene.Iterator import Iterator from UM.Scene.SceneNode import SceneNode from functools import cmp_to_key -## Iterator that returns a list of nodes in the order that they need to be printed -# If there is no solution an empty list is returned. -# Take note that the list of nodes can have children (that may or may not contain mesh data) class OneAtATimeIterator(Iterator.Iterator): + """Iterator that returns a list of nodes in the order that they need to be printed + + If there is no solution an empty list is returned. + Take note that the list of nodes can have children (that may or may not contain mesh data) + """ + def __init__(self, scene_node) -> None: super().__init__(scene_node) # Call super to make multiple inheritance work. self._hit_map = [[]] # type: List[List[bool]] # For each node, which other nodes this hits. A grid of booleans on which nodes hit which. self._original_node_list = [] # type: List[SceneNode] # The nodes that need to be checked for collisions. - ## Fills the ``_node_stack`` with a list of scene nodes that need to be - # printed in order. def _fillStack(self) -> None: + """Fills the ``_node_stack`` with a list of scene nodes that need to be printed in order. """ + node_list = [] for node in self._scene_node.getChildren(): if not issubclass(type(node), SceneNode): continue + # Node can't be printed, so don't bother sending it. + if getattr(node, "_outside_buildarea", False): + continue + if node.callDecoration("getConvexHull"): node_list.append(node) - if len(node_list) < 2: self._node_stack = node_list[:] return @@ -35,8 +41,8 @@ class OneAtATimeIterator(Iterator.Iterator): # Copy the list self._original_node_list = node_list[:] - ## Initialise the hit map (pre-compute all hits between all objects) - self._hit_map = [[self._checkHit(i,j) for i in node_list] for j in node_list] + # Initialise the hit map (pre-compute all hits between all objects) + self._hit_map = [[self._checkHit(i, j) for i in node_list] for j in node_list] # Check if we have to files that block each other. If this is the case, there is no solution! for a in range(0, len(node_list)): @@ -75,10 +81,14 @@ class OneAtATimeIterator(Iterator.Iterator): return True return False - ## Check for a node whether it hits any of the other nodes. - # \param node The node to check whether it collides with the other nodes. - # \param other_nodes The nodes to check for collisions. def _checkBlockMultiple(self, node: SceneNode, other_nodes: List[SceneNode]) -> bool: + """Check for a node whether it hits any of the other nodes. + + :param node: The node to check whether it collides with the other nodes. + :param other_nodes: The nodes to check for collisions. + :return: returns collision between nodes + """ + node_index = self._original_node_list.index(node) for other_node in other_nodes: other_node_index = self._original_node_list.index(other_node) @@ -86,14 +96,26 @@ class OneAtATimeIterator(Iterator.Iterator): return True return False - ## Calculate score simply sums the number of other objects it 'blocks' def _calculateScore(self, a: SceneNode, b: SceneNode) -> int: + """Calculate score simply sums the number of other objects it 'blocks' + + :param a: node + :param b: node + :return: sum of the number of other objects + """ + score_a = sum(self._hit_map[self._original_node_list.index(a)]) score_b = sum(self._hit_map[self._original_node_list.index(b)]) return score_a - score_b - ## Checks if A can be printed before B def _checkHit(self, a: SceneNode, b: SceneNode) -> bool: + """Checks if a can be printed before b + + :param a: node + :param b: node + :return: true if a can be printed before b + """ + if a == b: return False @@ -116,12 +138,14 @@ class OneAtATimeIterator(Iterator.Iterator): return False -## Internal object used to keep track of a possible order in which to print objects. class _ObjectOrder: - ## Creates the _ObjectOrder instance. - # \param order List of indices in which to print objects, ordered by printing - # order. - # \param todo: List of indices which are not yet inserted into the order list. + """Internal object used to keep track of a possible order in which to print objects.""" + def __init__(self, order: List[SceneNode], todo: List[SceneNode]) -> None: + """Creates the _ObjectOrder instance. + + :param order: List of indices in which to print objects, ordered by printing order. + :param todo: List of indices which are not yet inserted into the order list. + """ self.order = order self.todo = todo diff --git a/cura/Operations/PlatformPhysicsOperation.py b/cura/Operations/PlatformPhysicsOperation.py index 0d69320eec..e433b67a7b 100644 --- a/cura/Operations/PlatformPhysicsOperation.py +++ b/cura/Operations/PlatformPhysicsOperation.py @@ -6,8 +6,9 @@ from UM.Operations.GroupedOperation import GroupedOperation from UM.Scene.SceneNode import SceneNode -## A specialised operation designed specifically to modify the previous operation. class PlatformPhysicsOperation(Operation): + """A specialised operation designed specifically to modify the previous operation.""" + def __init__(self, node: SceneNode, translation: Vector) -> None: super().__init__() self._node = node diff --git a/cura/Operations/SetBuildPlateNumberOperation.py b/cura/Operations/SetBuildPlateNumberOperation.py index fd48cf47d9..8a5bdeb442 100644 --- a/cura/Operations/SetBuildPlateNumberOperation.py +++ b/cura/Operations/SetBuildPlateNumberOperation.py @@ -7,8 +7,9 @@ from UM.Operations.Operation import Operation from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator -## Simple operation to set the buildplate number of a scenenode. class SetBuildPlateNumberOperation(Operation): + """Simple operation to set the buildplate number of a scenenode.""" + def __init__(self, node: SceneNode, build_plate_nr: int) -> None: super().__init__() self._node = node diff --git a/cura/Operations/SetParentOperation.py b/cura/Operations/SetParentOperation.py index 7d71572a93..a8fab49395 100644 --- a/cura/Operations/SetParentOperation.py +++ b/cura/Operations/SetParentOperation.py @@ -5,33 +5,38 @@ from typing import Optional from UM.Scene.SceneNode import SceneNode from UM.Operations import Operation -from UM.Math.Vector import Vector - -## An operation that parents a scene node to another scene node. class SetParentOperation(Operation.Operation): - ## Initialises this SetParentOperation. - # - # \param node The node which will be reparented. - # \param parent_node The node which will be the parent. + """An operation that parents a scene node to another scene node.""" + def __init__(self, node: SceneNode, parent_node: Optional[SceneNode]) -> None: + """Initialises this SetParentOperation. + + :param node: The node which will be reparented. + :param parent_node: The node which will be the parent. + """ + super().__init__() self._node = node self._parent = parent_node self._old_parent = node.getParent() # To restore the previous parent in case of an undo. - ## Undoes the set-parent operation, restoring the old parent. def undo(self) -> None: + """Undoes the set-parent operation, restoring the old parent.""" + self._set_parent(self._old_parent) - ## Re-applies the set-parent operation. def redo(self) -> None: + """Re-applies the set-parent operation.""" + self._set_parent(self._parent) - ## Sets the parent of the node while applying transformations to the world-transform of the node stays the same. - # - # \param new_parent The new parent. Note: this argument can be None, which would hide the node from the scene. def _set_parent(self, new_parent: Optional[SceneNode]) -> None: + """Sets the parent of the node while applying transformations to the world-transform of the node stays the same. + + :param new_parent: The new parent. Note: this argument can be None, which would hide the node from the scene. + """ + if new_parent: current_parent = self._node.getParent() if current_parent: @@ -57,8 +62,10 @@ class SetParentOperation(Operation.Operation): self._node.setParent(new_parent) - ## Returns a programmer-readable representation of this operation. - # - # \return A programmer-readable representation of this operation. def __repr__(self) -> str: + """Returns a programmer-readable representation of this operation. + + :return: A programmer-readable representation of this operation. + """ + return "SetParentOperation(node = {0}, parent_node={1})".format(self._node, self._parent) diff --git a/cura/PickingPass.py b/cura/PickingPass.py index 75ee21ef41..eb190be16d 100644 --- a/cura/PickingPass.py +++ b/cura/PickingPass.py @@ -1,14 +1,16 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from typing import Optional, TYPE_CHECKING from UM.Qt.QtApplication import QtApplication +from UM.Logger import Logger from UM.Math.Vector import Vector from UM.Resources import Resources from UM.View.RenderPass import RenderPass from UM.View.GL.OpenGL import OpenGL +from UM.View.GL.ShaderProgram import InvalidShaderProgramError from UM.View.RenderBatch import RenderBatch from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator @@ -16,11 +18,15 @@ from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator if TYPE_CHECKING: from UM.View.GL.ShaderProgram import ShaderProgram -## A RenderPass subclass that renders a the distance of selectable objects from the active camera to a texture. -# The texture is used to map a 2d location (eg the mouse location) to a world space position -# -# Note that in order to increase precision, the 24 bit depth value is encoded into all three of the R,G & B channels class PickingPass(RenderPass): + """A :py:class:`Uranium.UM.View.RenderPass` subclass that renders a the distance of selectable objects from the + active camera to a texture. + + The texture is used to map a 2d location (eg the mouse location) to a world space position + + .. note:: that in order to increase precision, the 24 bit depth value is encoded into all three of the R,G & B channels + """ + def __init__(self, width: int, height: int) -> None: super().__init__("picking", width, height) @@ -31,7 +37,11 @@ class PickingPass(RenderPass): def render(self) -> None: if not self._shader: - self._shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "camera_distance.shader")) + try: + self._shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "camera_distance.shader")) + except InvalidShaderProgramError: + Logger.error("Unable to compile shader program: camera_distance.shader") + return width, height = self.getSize() self._gl.glViewport(0, 0, width, height) @@ -44,14 +54,20 @@ class PickingPass(RenderPass): # Fill up the batch with objects that can be sliced. ` for node in DepthFirstIterator(self._scene.getRoot()): #type: ignore #Ignore type error because iter() should get called automatically by Python syntax. if node.callDecoration("isSliceable") and node.getMeshData() and node.isVisible(): - batch.addItem(node.getWorldTransformation(), node.getMeshData()) + batch.addItem(node.getWorldTransformation(copy = False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix()) self.bind() batch.render(self._scene.getActiveCamera()) self.release() - ## Get the distance in mm from the camera to at a certain pixel coordinate. def getPickedDepth(self, x: int, y: int) -> float: + """Get the distance in mm from the camera to at a certain pixel coordinate. + + :param x: x component of coordinate vector in pixels + :param y: y component of coordinate vector in pixels + :return: distance in mm from the camera to pixel coordinate + """ + output = self.getOutput() window_size = self._renderer.getWindowSize() @@ -66,8 +82,14 @@ class PickingPass(RenderPass): distance = (distance & 0x00ffffff) / 1000. # drop the alpha channel and covert to mm return distance - ## Get the world coordinates of a picked point def getPickedPosition(self, x: int, y: int) -> Vector: + """Get the world coordinates of a picked point + + :param x: x component of coordinate vector in pixels + :param y: y component of coordinate vector in pixels + :return: vector of the world coordinate + """ + distance = self.getPickedDepth(x, y) camera = self._scene.getActiveCamera() if camera: diff --git a/cura/PlatformPhysics.py b/cura/PlatformPhysics.py index a411478b16..5fd2e70a1c 100755 --- a/cura/PlatformPhysics.py +++ b/cura/PlatformPhysics.py @@ -1,9 +1,11 @@ -# Copyright (c) 2015 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from PyQt5.QtCore import QTimer +from shapely.errors import TopologicalError # To capture errors if Shapely messes up. from UM.Application import Application +from UM.Logger import Logger from UM.Scene.SceneNode import SceneNode from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator from UM.Math.Vector import Vector @@ -93,15 +95,15 @@ class PlatformPhysics: # Ignore root, ourselves and anything that is not a normal SceneNode. if other_node is root or not issubclass(type(other_node), SceneNode) or other_node is node or other_node.callDecoration("getBuildPlateNumber") != node.callDecoration("getBuildPlateNumber"): continue - + # Ignore collisions of a group with it's own children if other_node in node.getAllChildren() or node in other_node.getAllChildren(): continue - + # Ignore collisions within a group if other_node.getParent() and node.getParent() and (other_node.getParent().callDecoration("isGroup") is not None or node.getParent().callDecoration("isGroup") is not None): continue - + # Ignore nodes that do not have the right properties set. if not other_node.callDecoration("getConvexHull") or not other_node.getBoundingBox(): continue @@ -136,7 +138,11 @@ class PlatformPhysics: own_convex_hull = node.callDecoration("getConvexHull") other_convex_hull = other_node.callDecoration("getConvexHull") if own_convex_hull and other_convex_hull: - overlap = own_convex_hull.translate(move_vector.x, move_vector.z).intersectsPolygon(other_convex_hull) + try: + 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! temp_move_vector = move_vector.set(x = move_vector.x + overlap[0] * self._move_factor, z = move_vector.z + overlap[1] * self._move_factor) @@ -175,7 +181,7 @@ class PlatformPhysics: if tool.getPluginId() == "TranslateTool": for node in Selection.getAllSelectedObjects(): - if node.getBoundingBox().bottom < 0: + if node.getBoundingBox() and node.getBoundingBox().bottom < 0: if not node.getDecorator(ZOffsetDecorator.ZOffsetDecorator): node.addDecorator(ZOffsetDecorator.ZOffsetDecorator()) diff --git a/cura/PreviewPass.py b/cura/PreviewPass.py index da60db2d99..47e8c367dc 100644 --- a/cura/PreviewPass.py +++ b/cura/PreviewPass.py @@ -1,10 +1,11 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Optional, TYPE_CHECKING, cast +from typing import Optional, TYPE_CHECKING, cast, List from UM.Application import Application +from UM.Logger import Logger from UM.Resources import Resources from UM.View.RenderPass import RenderPass @@ -20,9 +21,14 @@ if TYPE_CHECKING: from UM.Scene.Camera import Camera -# Make color brighter by normalizing it (maximum factor 2.5 brighter) -# color_list is a list of 4 elements: [r, g, b, a], each element is a float 0..1 -def prettier_color(color_list): +def prettier_color(color_list: List[float]) -> List[float]: + """Make color brighter by normalizing + + maximum factor 2.5 brighter + + :param color_list: a list of 4 elements: [r, g, b, a], each element is a float 0..1 + :return: a normalized list of 4 elements: [r, g, b, a], each element is a float 0..1 + """ maximum = max(color_list[:3]) if maximum > 0: factor = min(1 / maximum, 2.5) @@ -31,11 +37,14 @@ def prettier_color(color_list): return [min(i * factor, 1.0) for i in color_list] -## A render pass subclass that renders slicable objects with default parameters. -# It uses the active camera by default, but it can be overridden to use a different camera. -# -# This is useful to get a preview image of a scene taken from a different location as the active camera. class PreviewPass(RenderPass): + """A :py:class:`Uranium.UM.View.RenderPass` subclass that renders slicable objects with default parameters. + + It uses the active camera by default, but it can be overridden to use a different camera. + + This is useful to get a preview image of a scene taken from a different location as the active camera. + """ + def __init__(self, width: int, height: int) -> None: super().__init__("preview", width, height, 0) @@ -61,11 +70,14 @@ class PreviewPass(RenderPass): self._shader.setUniformValue("u_ambientColor", [0.1, 0.1, 0.1, 1.0]) self._shader.setUniformValue("u_specularColor", [0.6, 0.6, 0.6, 1.0]) self._shader.setUniformValue("u_shininess", 20.0) + self._shader.setUniformValue("u_renderError", 0.0) # We don't want any error markers!. self._shader.setUniformValue("u_faceId", -1) # Don't render any selected faces in the preview. + else: + Logger.error("Unable to compile shader program: overhang.shader") if not self._non_printing_shader: + self._non_printing_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader")) if self._non_printing_shader: - self._non_printing_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader")) self._non_printing_shader.setUniformValue("u_diffuseColor", [0.5, 0.5, 0.5, 0.5]) self._non_printing_shader.setUniformValue("u_opacity", 0.6) @@ -102,12 +114,12 @@ class PreviewPass(RenderPass): 1.0] uniforms["diffuse_color"] = prettier_color(diffuse_color) uniforms["diffuse_color_2"] = diffuse_color2 - batch_support_mesh.addItem(node.getWorldTransformation(), node.getMeshData(), uniforms = uniforms) + batch_support_mesh.addItem(node.getWorldTransformation(copy = False), node.getMeshData(), uniforms = uniforms) else: # Normal scene node uniforms = {} uniforms["diffuse_color"] = prettier_color(cast(CuraSceneNode, node).getDiffuseColor()) - batch.addItem(node.getWorldTransformation(), node.getMeshData(), uniforms = uniforms) + batch.addItem(node.getWorldTransformation(copy = False), node.getMeshData(), uniforms = uniforms) self.bind() diff --git a/cura/PrintJobPreviewImageProvider.py b/cura/PrintJobPreviewImageProvider.py index 8b46c6db37..321164adeb 100644 --- a/cura/PrintJobPreviewImageProvider.py +++ b/cura/PrintJobPreviewImageProvider.py @@ -10,8 +10,14 @@ class PrintJobPreviewImageProvider(QQuickImageProvider): def __init__(self): super().__init__(QQuickImageProvider.Image) - ## Request a new image. def requestImage(self, id: str, size: QSize) -> Tuple[QImage, QSize]: + """Request a new image. + + :param id: id of the requested image + :param size: is not used defaults to QSize(15, 15) + :return: an tuple containing the image and size + """ + # The id will have an uuid and an increment separated by a slash. As we don't care about the value of the # increment, we need to strip that first. uuid = id[id.find("/") + 1:] diff --git a/cura/PrinterOutput/FirmwareUpdater.py b/cura/PrinterOutput/FirmwareUpdater.py index 56e260a7f0..c4f3948c20 100644 --- a/cura/PrinterOutput/FirmwareUpdater.py +++ b/cura/PrinterOutput/FirmwareUpdater.py @@ -7,6 +7,8 @@ from enum import IntEnum from threading import Thread from typing import Union +from UM.Logger import Logger + MYPY = False if MYPY: from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice @@ -33,15 +35,22 @@ class FirmwareUpdater(QObject): else: self._firmware_file = firmware_file - self._setFirmwareUpdateState(FirmwareUpdateState.updating) + if self._firmware_file == "": + self._setFirmwareUpdateState(FirmwareUpdateState.firmware_not_found_error) + return - self._update_firmware_thread.start() + self._setFirmwareUpdateState(FirmwareUpdateState.updating) + try: + self._update_firmware_thread.start() + except RuntimeError: + Logger.warning("Could not start the update thread, since it's still running!") def _updateFirmware(self) -> None: raise NotImplementedError("_updateFirmware needs to be implemented") - ## Cleanup after a succesful update def _cleanupAfterUpdate(self) -> None: + """Cleanup after a succesful update""" + # Clean up for next attempt. self._update_firmware_thread = Thread(target=self._updateFirmware, daemon=True, name = "FirmwareUpdateThread") self._firmware_file = "" diff --git a/cura/PrinterOutput/Models/ExtruderConfigurationModel.py b/cura/PrinterOutput/Models/ExtruderConfigurationModel.py index 4a1cf4916f..4fbf951f45 100644 --- a/cura/PrinterOutput/Models/ExtruderConfigurationModel.py +++ b/cura/PrinterOutput/Models/ExtruderConfigurationModel.py @@ -47,10 +47,13 @@ class ExtruderConfigurationModel(QObject): def hotendID(self) -> Optional[str]: return self._hotend_id - ## This method is intended to indicate whether the configuration is valid or not. - # The method checks if the mandatory fields are or not set - # At this moment is always valid since we allow to have empty material and variants. def isValid(self) -> bool: + """This method is intended to indicate whether the configuration is valid or not. + + The method checks if the mandatory fields are or not set + At this moment is always valid since we allow to have empty material and variants. + """ + return True def __str__(self) -> str: @@ -71,11 +74,11 @@ class ExtruderConfigurationModel(QObject): # Empty materials should be ignored for comparison if self.activeMaterial is not None and other.activeMaterial is not None: if self.activeMaterial.guid != other.activeMaterial.guid: - if self.activeMaterial.guid != "" and other.activeMaterial.guid != "": - return False - else: + if self.activeMaterial.guid == "" and other.activeMaterial.guid == "": # At this point there is no material, so it doesn't matter what the hotend is. return True + else: + return False if self.hotendID != other.hotendID: return False diff --git a/cura/PrinterOutput/Models/ExtruderOutputModel.py b/cura/PrinterOutput/Models/ExtruderOutputModel.py index 889e140312..bcd0f579c2 100644 --- a/cura/PrinterOutput/Models/ExtruderOutputModel.py +++ b/cura/PrinterOutput/Models/ExtruderOutputModel.py @@ -54,8 +54,9 @@ class ExtruderOutputModel(QObject): def updateActiveMaterial(self, material: Optional["MaterialOutputModel"]) -> None: self._extruder_configuration.setMaterial(material) - ## Update the hotend temperature. This only changes it locally. def updateHotendTemperature(self, temperature: float) -> None: + """Update the hotend temperature. This only changes it locally.""" + if self._hotend_temperature != temperature: self._hotend_temperature = temperature self.hotendTemperatureChanged.emit() @@ -65,9 +66,10 @@ class ExtruderOutputModel(QObject): self._target_hotend_temperature = temperature self.targetHotendTemperatureChanged.emit() - ## Set the target hotend temperature. This ensures that it's actually sent to the remote. @pyqtSlot(float) def setTargetHotendTemperature(self, temperature: float) -> None: + """Set the target hotend temperature. This ensures that it's actually sent to the remote.""" + self._printer.getController().setTargetHotendTemperature(self._printer, self, temperature) self.updateTargetHotendTemperature(temperature) @@ -97,17 +99,19 @@ class ExtruderOutputModel(QObject): self._is_preheating = pre_heating self.isPreheatingChanged.emit() - @pyqtProperty(bool, notify=isPreheatingChanged) + @pyqtProperty(bool, notify = isPreheatingChanged) def isPreheating(self) -> bool: return self._is_preheating - ## Pre-heats the extruder before printer. - # - # \param temperature The temperature to heat the extruder to, in degrees - # Celsius. - # \param duration How long the bed should stay warm, in seconds. @pyqtSlot(float, float) def preheatHotend(self, temperature: float, duration: float) -> None: + """Pre-heats the extruder before printer. + + :param temperature: The temperature to heat the extruder to, in degrees + Celsius. + :param duration: How long the bed should stay warm, in seconds. + """ + self._printer._controller.preheatHotend(self, temperature, duration) @pyqtSlot() diff --git a/cura/PrinterOutput/Models/PrinterConfigurationModel.py b/cura/PrinterOutput/Models/PrinterConfigurationModel.py index 52c7b6f960..54f52134b2 100644 --- a/cura/PrinterOutput/Models/PrinterConfigurationModel.py +++ b/cura/PrinterOutput/Models/PrinterConfigurationModel.py @@ -48,9 +48,11 @@ class PrinterConfigurationModel(QObject): def buildplateConfiguration(self) -> str: return self._buildplate_configuration - ## This method is intended to indicate whether the configuration is valid or not. - # The method checks if the mandatory fields are or not set def isValid(self) -> bool: + """This method is intended to indicate whether the configuration is valid or not. + + The method checks if the mandatory fields are or not set + """ if not self._extruder_configurations: return False for configuration in self._extruder_configurations: @@ -97,9 +99,11 @@ class PrinterConfigurationModel(QObject): return True - ## The hash function is used to compare and create unique sets. The configuration is unique if the configuration - # of the extruders is unique (the order of the extruders matters), and the type and buildplate is the same. def __hash__(self): + """The hash function is used to compare and create unique sets. The configuration is unique if the configuration + + of the extruders is unique (the order of the extruders matters), and the type and buildplate is the same. + """ extruder_hash = hash(0) first_extruder = None for configuration in self._extruder_configurations: diff --git a/cura/PrinterOutput/Models/PrinterOutputModel.py b/cura/PrinterOutput/Models/PrinterOutputModel.py index 37135bf663..37464b0b7d 100644 --- a/cura/PrinterOutput/Models/PrinterOutputModel.py +++ b/cura/PrinterOutput/Models/PrinterOutputModel.py @@ -163,13 +163,15 @@ class PrinterOutputModel(QObject): def moveHead(self, x: float = 0, y: float = 0, z: float = 0, speed: float = 3000) -> None: self._controller.moveHead(self, x, y, z, speed) - ## Pre-heats the heated bed of the printer. - # - # \param temperature The temperature to heat the bed to, in degrees - # Celsius. - # \param duration How long the bed should stay warm, in seconds. @pyqtSlot(float, float) def preheatBed(self, temperature: float, duration: float) -> None: + """Pre-heats the heated bed of the printer. + + :param temperature: The temperature to heat the bed to, in degrees + Celsius. + :param duration: How long the bed should stay warm, in seconds. + """ + self._controller.preheatBed(self, temperature, duration) @pyqtSlot() @@ -200,8 +202,9 @@ class PrinterOutputModel(QObject): self._unique_name = unique_name self.nameChanged.emit() - ## Update the bed temperature. This only changes it locally. def updateBedTemperature(self, temperature: float) -> None: + """Update the bed temperature. This only changes it locally.""" + if self._bed_temperature != temperature: self._bed_temperature = temperature self.bedTemperatureChanged.emit() @@ -211,9 +214,10 @@ class PrinterOutputModel(QObject): self._target_bed_temperature = temperature self.targetBedTemperatureChanged.emit() - ## Set the target bed temperature. This ensures that it's actually sent to the remote. @pyqtSlot(float) def setTargetBedTemperature(self, temperature: float) -> None: + """Set the target bed temperature. This ensures that it's actually sent to the remote.""" + self._controller.setTargetBedTemperature(self, temperature) self.updateTargetBedTemperature(temperature) diff --git a/cura/PrinterOutput/NetworkMJPGImage.py b/cura/PrinterOutput/NetworkMJPGImage.py index 522d684085..0bfcfab764 100644 --- a/cura/PrinterOutput/NetworkMJPGImage.py +++ b/cura/PrinterOutput/NetworkMJPGImage.py @@ -32,8 +32,9 @@ class NetworkMJPGImage(QQuickPaintedItem): self.setAntialiasing(True) - ## Ensure that close gets called when object is destroyed def __del__(self) -> None: + """Ensure that close gets called when object is destroyed""" + self.stop() @@ -111,7 +112,7 @@ class NetworkMJPGImage(QQuickPaintedItem): if not self._image_reply.isFinished(): self._image_reply.close() - except Exception as e: # RuntimeError + except Exception: # RuntimeError pass # It can happen that the wrapped c++ object is already deleted. self._image_reply = None diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 60be5bc8f3..2690c2651f 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -84,8 +84,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): def _compressGCode(self) -> Optional[bytes]: self._compressing_gcode = True - ## Mash the data into single string max_chars_per_line = int(1024 * 1024 / 4) # 1/4 MB per line. + """Mash the data into single string""" file_data_bytes_list = [] batched_lines = [] batched_lines_count = 0 @@ -145,9 +145,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) return request - ## This method was only available privately before, but it was actually called from SendMaterialJob.py. - # We now have a public equivalent as well. We did not remove the private one as plugins might be using that. def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: + """This method was only available privately before, but it was actually called from SendMaterialJob.py. + + We now have a public equivalent as well. We did not remove the private one as plugins might be using that. + """ return self._createFormPart(content_header, data, content_type) def _createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: @@ -163,8 +165,9 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): part.setBody(data) return part - ## Convenience function to get the username, either from the cloud or from the OS. def _getUserName(self) -> str: + """Convenience function to get the username, either from the cloud or from the OS.""" + # check first if we are logged in with the Ultimaker Account account = CuraApplication.getInstance().getCuraAPI().account # type: Account if account and account.isLoggedIn: @@ -187,15 +190,17 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._createNetworkManager() assert (self._manager is not None) - ## Sends a put request to the given path. - # \param url: The path after the API prefix. - # \param data: The data to be sent in the body - # \param content_type: The content type of the body data. - # \param on_finished: The function to call when the response is received. - # \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = "application/json", on_finished: Optional[Callable[[QNetworkReply], None]] = None, on_progress: Optional[Callable[[int, int], None]] = None) -> None: + """Sends a put request to the given path. + + :param url: The path after the API prefix. + :param data: The data to be sent in the body + :param content_type: The content type of the body data. + :param on_finished: The function to call when the response is received. + :param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. + """ self._validateManager() request = self._createEmptyRequest(url, content_type = content_type) @@ -212,10 +217,12 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): if on_progress is not None: reply.uploadProgress.connect(on_progress) - ## Sends a delete request to the given path. - # \param url: The path after the API prefix. - # \param on_finished: The function to be call when the response is received. def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + """Sends a delete request to the given path. + + :param url: The path after the API prefix. + :param on_finished: The function to be call when the response is received. + """ self._validateManager() request = self._createEmptyRequest(url) @@ -228,10 +235,12 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): reply = self._manager.deleteResource(request) self._registerOnFinishedCallback(reply, on_finished) - ## Sends a get request to the given path. - # \param url: The path after the API prefix. - # \param on_finished: The function to be call when the response is received. def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + """Sends a get request to the given path. + + :param url: The path after the API prefix. + :param on_finished: The function to be call when the response is received. + """ self._validateManager() request = self._createEmptyRequest(url) @@ -244,14 +253,18 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): reply = self._manager.get(request) self._registerOnFinishedCallback(reply, on_finished) - ## Sends a post request to the given path. - # \param url: The path after the API prefix. - # \param data: The data to be sent in the body - # \param on_finished: The function to call when the response is received. - # \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. def post(self, url: str, data: Union[str, bytes], on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Optional[Callable[[int, int], None]] = None) -> None: + + """Sends a post request to the given path. + + :param url: The path after the API prefix. + :param data: The data to be sent in the body + :param on_finished: The function to call when the response is received. + :param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. + """ + self._validateManager() request = self._createEmptyRequest(url) @@ -318,10 +331,13 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): if on_finished is not None: self._onFinishedCallbacks[reply.url().toString() + str(reply.operation())] = on_finished - ## This method checks if the name of the group stored in the definition container is correct. - # After updating from 3.2 to 3.3 some group names may be temporary. If there is a mismatch in the name of the group - # then all the container stacks are updated, both the current and the hidden ones. def _checkCorrectGroupName(self, device_id: str, group_name: str) -> None: + """This method checks if the name of the group stored in the definition container is correct. + + After updating from 3.2 to 3.3 some group names may be temporary. If there is a mismatch in the name of the group + then all the container stacks are updated, both the current and the hidden ones. + """ + global_container_stack = CuraApplication.getInstance().getGlobalContainerStack() active_machine_network_name = CuraApplication.getInstance().getMachineManager().activeMachineNetworkKey() if global_container_stack and device_id == active_machine_network_name: @@ -366,32 +382,38 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): def getProperties(self): return self._properties - ## Get the unique key of this machine - # \return key String containing the key of the machine. @pyqtProperty(str, constant = True) def key(self) -> str: + """Get the unique key of this machine + + :return: key String containing the key of the machine. + """ return self._id - ## The IP address of the printer. @pyqtProperty(str, constant = True) def address(self) -> str: + """The IP address of the printer.""" + return self._properties.get(b"address", b"").decode("utf-8") - ## Name of the printer (as returned from the ZeroConf properties) @pyqtProperty(str, constant = True) def name(self) -> str: + """Name of the printer (as returned from the ZeroConf properties)""" + return self._properties.get(b"name", b"").decode("utf-8") - ## Firmware version (as returned from the ZeroConf properties) @pyqtProperty(str, constant = True) def firmwareVersion(self) -> str: + """Firmware version (as returned from the ZeroConf properties)""" + return self._properties.get(b"firmware_version", b"").decode("utf-8") @pyqtProperty(str, constant = True) def printerType(self) -> str: return self._properties.get(b"printer_type", b"Unknown").decode("utf-8") - ## IP adress of this printer @pyqtProperty(str, constant = True) def ipAddress(self) -> str: + """IP adress of this printer""" + return self._address diff --git a/cura/PrinterOutput/Peripheral.py b/cura/PrinterOutput/Peripheral.py index 2693b82c36..27d127832b 100644 --- a/cura/PrinterOutput/Peripheral.py +++ b/cura/PrinterOutput/Peripheral.py @@ -2,15 +2,19 @@ # Cura is released under the terms of the LGPLv3 or higher. -## Data class that represents a peripheral for a printer. -# -# Output device plug-ins may specify that the printer has a certain set of -# peripherals. This set is then possibly shown in the interface of the monitor -# stage. class Peripheral: - ## Constructs the peripheral. - # \param type A unique ID for the type of peripheral. - # \param name A human-readable name for the peripheral. + """Data class that represents a peripheral for a printer. + + Output device plug-ins may specify that the printer has a certain set of + peripherals. This set is then possibly shown in the interface of the monitor + stage. + """ + def __init__(self, peripheral_type: str, name: str) -> None: + """Constructs the peripheral. + + :param peripheral_type: A unique ID for the type of peripheral. + :param name: A human-readable name for the peripheral. + """ self.type = peripheral_type self.name = name diff --git a/cura/PrinterOutput/PrinterOutputDevice.py b/cura/PrinterOutput/PrinterOutputDevice.py index 0e0ad488b1..526d713748 100644 --- a/cura/PrinterOutput/PrinterOutputDevice.py +++ b/cura/PrinterOutput/PrinterOutputDevice.py @@ -24,8 +24,9 @@ if MYPY: i18n_catalog = i18nCatalog("cura") -## The current processing state of the backend. class ConnectionState(IntEnum): + """The current processing state of the backend.""" + Closed = 0 Connecting = 1 Connected = 2 @@ -40,17 +41,19 @@ class ConnectionType(IntEnum): CloudConnection = 3 -## Printer output device adds extra interface options on top of output device. -# -# The assumption is made the printer is a FDM printer. -# -# Note that a number of settings are marked as "final". This is because decorators -# are not inherited by children. To fix this we use the private counter part of those -# functions to actually have the implementation. -# -# For all other uses it should be used in the same way as a "regular" OutputDevice. @signalemitter class PrinterOutputDevice(QObject, OutputDevice): + """Printer output device adds extra interface options on top of output device. + + The assumption is made the printer is a FDM printer. + + Note that a number of settings are marked as "final". This is because decorators + are not inherited by children. To fix this we use the private counter part of those + functions to actually have the implementation. + + For all other uses it should be used in the same way as a "regular" OutputDevice. + """ + printersChanged = pyqtSignal() connectionStateChanged = pyqtSignal(str) @@ -184,26 +187,30 @@ class PrinterOutputDevice(QObject, OutputDevice): if self._monitor_item is None: self._monitor_item = QtApplication.getInstance().createQmlComponent(self._monitor_view_qml_path, {"OutputDevice": self}) - ## Attempt to establish connection def connect(self) -> None: + """Attempt to establish connection""" + self.setConnectionState(ConnectionState.Connecting) self._update_timer.start() - ## Attempt to close the connection def close(self) -> None: + """Attempt to close the connection""" + self._update_timer.stop() self.setConnectionState(ConnectionState.Closed) - ## Ensure that close gets called when object is destroyed def __del__(self) -> None: + """Ensure that close gets called when object is destroyed""" + self.close() @pyqtProperty(bool, notify = acceptsCommandsChanged) def acceptsCommands(self) -> bool: return self._accepts_commands - ## Set a flag to signal the UI that the printer is not (yet) ready to receive commands def _setAcceptsCommands(self, accepts_commands: bool) -> None: + """Set a flag to signal the UI that the printer is not (yet) ready to receive commands""" + if self._accepts_commands != accepts_commands: self._accepts_commands = accepts_commands @@ -241,16 +248,20 @@ class PrinterOutputDevice(QObject, OutputDevice): # At this point there may be non-updated configurations self._updateUniqueConfigurations() - ## Set the device firmware name - # - # \param name The name of the firmware. def _setFirmwareName(self, name: str) -> None: + """Set the device firmware name + + :param name: The name of the firmware. + """ + self._firmware_name = name - ## Get the name of device firmware - # - # This name can be used to define device type def getFirmwareName(self) -> Optional[str]: + """Get the name of device firmware + + This name can be used to define device type + """ + return self._firmware_name def getFirmwareUpdater(self) -> Optional["FirmwareUpdater"]: diff --git a/cura/ReaderWriters/ProfileReader.py b/cura/ReaderWriters/ProfileReader.py index 460fce823e..0d53bdebac 100644 --- a/cura/ReaderWriters/ProfileReader.py +++ b/cura/ReaderWriters/ProfileReader.py @@ -10,15 +10,19 @@ class NoProfileException(Exception): pass -## A type of plug-ins that reads profiles from a file. -# -# The profile is then stored as instance container of the type user profile. class ProfileReader(PluginObject): + """A type of plug-ins that reads profiles from a file. + + The profile is then stored as instance container of the type user profile. + """ + def __init__(self): super().__init__() - ## Read profile data from a file and return a filled profile. - # - # \return \type{Profile|Profile[]} The profile that was obtained from the file or a list of Profiles. def read(self, file_name): + """Read profile data from a file and return a filled profile. + + :return: :type{Profile|Profile[]} The profile that was obtained from the file or a list of Profiles. + """ + raise NotImplementedError("Profile reader plug-in was not correctly implemented. The read function was not implemented.") diff --git a/cura/ReaderWriters/ProfileWriter.py b/cura/ReaderWriters/ProfileWriter.py index 5f81dc28c3..987924ccbf 100644 --- a/cura/ReaderWriters/ProfileWriter.py +++ b/cura/ReaderWriters/ProfileWriter.py @@ -3,23 +3,29 @@ from UM.PluginObject import PluginObject -## Base class for profile writer plugins. -# -# This class defines a write() function to write profiles to files with. + class ProfileWriter(PluginObject): - ## Initialises the profile writer. - # - # This currently doesn't do anything since the writer is basically static. + """Base class for profile writer plugins. + + This class defines a write() function to write profiles to files with. + """ + def __init__(self): + """Initialises the profile writer. + + This currently doesn't do anything since the writer is basically static. + """ + super().__init__() - ## Writes a profile to the specified file path. - # - # The profile writer may write its own file format to the specified file. - # - # \param path \type{string} The file to output to. - # \param profiles \type{Profile} or \type{List} The profile(s) to write to the file. - # \return \code True \endcode if the writing was successful, or \code - # False \endcode if it wasn't. def write(self, path, profiles): + """Writes a profile to the specified file path. + + The profile writer may write its own file format to the specified file. + + :param path: :type{string} The file to output to. + :param profiles: :type{Profile} or :type{List} The profile(s) to write to the file. + :return: True if the writing was successful, or False if it wasn't. + """ + raise NotImplementedError("Profile writer plugin was not correctly implemented. No write was specified.") diff --git a/cura/Scene/BuildPlateDecorator.py b/cura/Scene/BuildPlateDecorator.py index cff9f88f62..9dd9d3dc24 100644 --- a/cura/Scene/BuildPlateDecorator.py +++ b/cura/Scene/BuildPlateDecorator.py @@ -2,8 +2,9 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator from cura.Scene.CuraSceneNode import CuraSceneNode -## Make a SceneNode build plate aware CuraSceneNode objects all have this decorator. class BuildPlateDecorator(SceneNodeDecorator): + """Make a SceneNode build plate aware CuraSceneNode objects all have this decorator.""" + def __init__(self, build_plate_number: int = -1) -> None: super().__init__() self._build_plate_number = build_plate_number diff --git a/cura/Scene/ConvexHullDecorator.py b/cura/Scene/ConvexHullDecorator.py index 2a160f6069..36697b7c57 100644 --- a/cura/Scene/ConvexHullDecorator.py +++ b/cura/Scene/ConvexHullDecorator.py @@ -1,11 +1,10 @@ -# Copyright (c) 2016 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from PyQt5.QtCore import QTimer from UM.Application import Application from UM.Math.Polygon import Polygon - from UM.Scene.SceneNodeDecorator import SceneNodeDecorator from UM.Settings.ContainerRegistry import ContainerRegistry @@ -23,9 +22,12 @@ if TYPE_CHECKING: from UM.Math.Matrix import Matrix -## The convex hull decorator is a scene node decorator that adds the convex hull functionality to a scene node. -# If a scene node has a convex hull decorator, it will have a shadow in which other objects can not be printed. class ConvexHullDecorator(SceneNodeDecorator): + """The convex hull decorator is a scene node decorator that adds the convex hull functionality to a scene node. + + If a scene node has a convex hull decorator, it will have a shadow in which other objects can not be printed. + """ + def __init__(self) -> None: super().__init__() @@ -36,8 +38,10 @@ class ConvexHullDecorator(SceneNodeDecorator): # Make sure the timer is created on the main thread self._recompute_convex_hull_timer = None # type: Optional[QTimer] + self._timer_scheduled_to_be_created = False from cura.CuraApplication import CuraApplication if CuraApplication.getInstance() is not None: + self._timer_scheduled_to_be_created = True CuraApplication.getInstance().callLater(self.createRecomputeConvexHullTimer) self._raft_thickness = 0.0 @@ -45,8 +49,12 @@ class ConvexHullDecorator(SceneNodeDecorator): self._build_volume.raftThicknessChanged.connect(self._onChanged) CuraApplication.getInstance().globalContainerStackChanged.connect(self._onGlobalStackChanged) - CuraApplication.getInstance().getController().toolOperationStarted.connect(self._onChanged) - CuraApplication.getInstance().getController().toolOperationStopped.connect(self._onChanged) + controller = CuraApplication.getInstance().getController() + controller.toolOperationStarted.connect(self._onChanged) + controller.toolOperationStopped.connect(self._onChanged) + #CuraApplication.getInstance().sceneBoundingBoxChanged.connect(self._onChanged) + + self._root = Application.getInstance().getController().getScene().getRoot() self._onGlobalStackChanged() @@ -72,13 +80,16 @@ class ConvexHullDecorator(SceneNodeDecorator): self._onChanged() - ## Force that a new (empty) object is created upon copy. def __deepcopy__(self, memo): + """Force that a new (empty) object is created upon copy.""" + return ConvexHullDecorator() - ## The polygon representing the 2D adhesion area. - # If no adhesion is used, the regular convex hull is returned def getAdhesionArea(self) -> Optional[Polygon]: + """The polygon representing the 2D adhesion area. + + If no adhesion is used, the regular convex hull is returned + """ if self._node is None: return None @@ -88,9 +99,11 @@ class ConvexHullDecorator(SceneNodeDecorator): return self._add2DAdhesionMargin(hull) - ## Get the unmodified 2D projected convex hull of the node (if any) - # In case of one-at-a-time, this includes adhesion and head+fans clearance def getConvexHull(self) -> Optional[Polygon]: + """Get the unmodified 2D projected convex hull of the node (if any) + + In case of one-at-a-time, this includes adhesion and head+fans clearance + """ if self._node is None: return None if self._node.callDecoration("isNonPrintingMesh"): @@ -106,9 +119,11 @@ class ConvexHullDecorator(SceneNodeDecorator): return self._compute2DConvexHull() - ## For one at the time this is the convex hull of the node with the full head size - # In case of printing all at once this is None. def getConvexHullHeadFull(self) -> Optional[Polygon]: + """For one at the time this is the convex hull of the node with the full head size + + In case of printing all at once this is None. + """ if self._node is None: return None @@ -124,10 +139,12 @@ class ConvexHullDecorator(SceneNodeDecorator): return False return bool(parent.callDecoration("isGroup")) - ## Get convex hull of the object + head size - # In case of printing all at once this is None. - # For one at the time this is area with intersection of mirrored head def getConvexHullHead(self) -> Optional[Polygon]: + """Get convex hull of the object + head size + + In case of printing all at once this is None. + For one at the time this is area with intersection of mirrored head + """ if self._node is None: return None if self._node.callDecoration("isNonPrintingMesh"): @@ -140,10 +157,12 @@ class ConvexHullDecorator(SceneNodeDecorator): return head_with_fans_with_adhesion_margin return None - ## Get convex hull of the node - # In case of printing all at once this None?? - # For one at the time this is the area without the head. def getConvexHullBoundary(self) -> Optional[Polygon]: + """Get convex hull of the node + + In case of printing all at once this None?? + For one at the time this is the area without the head. + """ if self._node is None: return None @@ -155,10 +174,12 @@ class ConvexHullDecorator(SceneNodeDecorator): return self._compute2DConvexHull() return None - ## Get the buildplate polygon where will be printed - # In case of printing all at once this is the same as convex hull (no individual adhesion) - # For one at the time this includes the adhesion area def getPrintingArea(self) -> Optional[Polygon]: + """Get the buildplate polygon where will be printed + + In case of printing all at once this is the same as convex hull (no individual adhesion) + For one at the time this includes the adhesion area + """ if self._isSingularOneAtATimeNode(): # In one-at-a-time mode, every printed object gets it's own adhesion printing_area = self.getAdhesionArea() @@ -166,31 +187,29 @@ class ConvexHullDecorator(SceneNodeDecorator): printing_area = self.getConvexHull() return printing_area - ## The same as recomputeConvexHull, but using a timer if it was set. def recomputeConvexHullDelayed(self) -> None: + """The same as recomputeConvexHull, but using a timer if it was set.""" if self._recompute_convex_hull_timer is not None: self._recompute_convex_hull_timer.start() else: - self.recomputeConvexHull() + from cura.CuraApplication import CuraApplication + if not self._timer_scheduled_to_be_created: + # The timer is not created and we never scheduled it. Time to create it now! + CuraApplication.getInstance().callLater(self.createRecomputeConvexHullTimer) + # Now we know for sure that the timer has been scheduled for creation, so we can try this again. + CuraApplication.getInstance().callLater(self.recomputeConvexHullDelayed) def recomputeConvexHull(self) -> None: - controller = Application.getInstance().getController() - root = controller.getScene().getRoot() - if self._node is None or controller.isToolOperationActive() or not self.__isDescendant(root, self._node): - # If the tool operation is still active, we need to compute the convex hull later after the controller is - # no longer active. - if controller.isToolOperationActive(): - self.recomputeConvexHullDelayed() - return - + if self._node is None or not self.__isDescendant(self._root, self._node): if self._convex_hull_node: + # Convex hull node still exists, but the node is removed or no longer in the scene. self._convex_hull_node.setParent(None) self._convex_hull_node = None return if self._convex_hull_node: self._convex_hull_node.setParent(None) - hull_node = ConvexHullNode.ConvexHullNode(self._node, self.getPrintingArea(), self._raft_thickness, root) + hull_node = ConvexHullNode.ConvexHullNode(self._node, self.getPrintingArea(), self._raft_thickness, self._root) self._convex_hull_node = hull_node def _onSettingValueChanged(self, key: str, property_name: str) -> None: @@ -217,7 +236,7 @@ class ConvexHullDecorator(SceneNodeDecorator): if self._node is None: return None if self._node.callDecoration("isGroup"): - points = numpy.zeros((0, 2), dtype=numpy.int32) + points = numpy.zeros((0, 2), dtype = numpy.int32) for child in self._node.getChildren(): child_hull = child.callDecoration("_compute2DConvexHull") if child_hull: @@ -244,16 +263,17 @@ class ConvexHullDecorator(SceneNodeDecorator): return offset_hull else: + convex_hull = Polygon([]) offset_hull = Polygon([]) mesh = self._node.getMeshData() if mesh is None: return Polygon([]) # Node has no mesh data, so just return an empty Polygon. - world_transform = self._node.getWorldTransformation() + world_transform = self._node.getWorldTransformation(copy = True) # Check the cache if mesh is self._2d_convex_hull_mesh and world_transform == self._2d_convex_hull_mesh_world_transform: - return self._2d_convex_hull_mesh_result + return self._offsetHull(self._2d_convex_hull_mesh_result) vertex_data = mesh.getConvexHullTransformedVertices(world_transform) # Don't use data below 0. @@ -261,7 +281,7 @@ class ConvexHullDecorator(SceneNodeDecorator): # Do not throw away vertices: the convex hull may be too small and objects can collide. # vertex_data = vertex_data[vertex_data[:,1] >= -0.01] - if len(vertex_data) >= 4: # type: ignore # mypy and numpy don't play along well just yet. + if vertex_data is not None and len(vertex_data) >= 4: # type: ignore # mypy and numpy don't play along well just yet. # Round the vertex data to 1/10th of a mm, then remove all duplicate vertices # This is done to greatly speed up further convex hull calculations as the convex hull # becomes much less complex when dealing with highly detailed models. @@ -288,7 +308,7 @@ class ConvexHullDecorator(SceneNodeDecorator): # Store the result in the cache self._2d_convex_hull_mesh = mesh self._2d_convex_hull_mesh_world_transform = world_transform - self._2d_convex_hull_mesh_result = offset_hull + self._2d_convex_hull_mesh_result = convex_hull return offset_hull @@ -318,9 +338,11 @@ class ConvexHullDecorator(SceneNodeDecorator): return convex_hull.getMinkowskiHull(head_and_fans) return None - ## Compensate given 2D polygon with adhesion margin - # \return 2D polygon with added margin def _add2DAdhesionMargin(self, poly: Polygon) -> Polygon: + """Compensate given 2D polygon with adhesion margin + + :return: 2D polygon with added margin + """ if not self._global_stack: return Polygon() # Compensate for raft/skirt/brim @@ -351,17 +373,48 @@ class ConvexHullDecorator(SceneNodeDecorator): poly = poly.getMinkowskiHull(extra_margin_polygon) return poly - ## Offset the convex hull with settings that influence the collision area. - # - # \param convex_hull Polygon of the original convex hull. - # \return New Polygon instance that is offset with everything that - # influences the collision area. def _offsetHull(self, convex_hull: Polygon) -> Polygon: + """Offset the convex hull with settings that influence the collision area. + + :param convex_hull: Polygon of the original convex hull. + :return: New Polygon instance that is offset with everything that + influences the collision area. + """ + # Shrinkage compensation. + if not self._global_stack: # Should never happen. + return convex_hull + scale_factor = self._global_stack.getProperty("material_shrinkage_percentage", "value") / 100.0 + result = convex_hull + if scale_factor != 1.0 and not self.getNode().callDecoration("isGroup"): + center = None + if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time": + # Find the root node that's placed in the scene; the root of the mesh group. + ancestor = self.getNode() + while ancestor.getParent() != self._root: + ancestor = ancestor.getParent() + center = ancestor.getBoundingBox().center + else: + # Find the bounding box of the entire scene, which is all one mesh group then. + aabb = None + for printed_node in self._root.getChildren(): + if not printed_node.callDecoration("isSliceable") and not printed_node.callDecoration("isGroup"): + continue # Not a printed node. + if aabb is None: + aabb = printed_node.getBoundingBox() + else: + aabb = aabb + printed_node.getBoundingBox() + if aabb: + center = aabb.center + if center: + result = convex_hull.scale(scale_factor, [center.x, center.z]) # Yes, use Z instead of Y. Mixed conventions there with how the OpenGL coordinates are transmitted. + + # Horizontal expansion. horizontal_expansion = max( self._getSettingProperty("xy_offset", "value"), self._getSettingProperty("xy_offset_layer_0", "value") ) + # Mold. mold_width = 0 if self._getSettingProperty("mold_enabled", "value"): mold_width = self._getSettingProperty("mold_width", "value") @@ -373,14 +426,13 @@ class ConvexHullDecorator(SceneNodeDecorator): [hull_offset, hull_offset], [hull_offset, -hull_offset] ], numpy.float32)) - return convex_hull.getMinkowskiHull(expansion_polygon) + return result.getMinkowskiHull(expansion_polygon) else: - return convex_hull + return result def _onChanged(self, *args) -> None: self._raft_thickness = self._build_volume.getRaftThickness() - if not args or args[0] == self._node: - self.recomputeConvexHullDelayed() + self.recomputeConvexHullDelayed() def _onGlobalStackChanged(self) -> None: if self._global_stack: @@ -402,8 +454,9 @@ class ConvexHullDecorator(SceneNodeDecorator): self._onChanged() - ## Private convenience function to get a setting from the correct extruder (as defined by limit_to_extruder property). def _getSettingProperty(self, setting_key: str, prop: str = "value") -> Any: + """Private convenience function to get a setting from the correct extruder (as defined by limit_to_extruder property).""" + if self._global_stack is None or self._node is None: return None per_mesh_stack = self._node.callDecoration("getStack") @@ -423,16 +476,18 @@ class ConvexHullDecorator(SceneNodeDecorator): # Limit_to_extruder is set. The global stack handles this then return self._global_stack.getProperty(setting_key, prop) - ## Returns True if node is a descendant or the same as the root node. def __isDescendant(self, root: "SceneNode", node: Optional["SceneNode"]) -> bool: + """Returns True if node is a descendant or the same as the root node.""" + if node is None: return False if root is node: return True return self.__isDescendant(root, node.getParent()) - ## True if print_sequence is one_at_a_time and _node is not part of a group def _isSingularOneAtATimeNode(self) -> bool: + """True if print_sequence is one_at_a_time and _node is not part of a group""" + if self._node is None: return False return self._global_stack is not None \ @@ -443,7 +498,8 @@ class ConvexHullDecorator(SceneNodeDecorator): "adhesion_type", "raft_margin", "print_sequence", "skirt_gap", "skirt_line_count", "skirt_brim_line_width", "skirt_distance", "brim_line_count"] - ## Settings that change the convex hull. - # - # If these settings change, the convex hull should be recalculated. - _influencing_settings = {"xy_offset", "xy_offset_layer_0", "mold_enabled", "mold_width", "anti_overhang_mesh", "infill_mesh", "cutting_mesh"} + _influencing_settings = {"xy_offset", "xy_offset_layer_0", "mold_enabled", "mold_width", "anti_overhang_mesh", "infill_mesh", "cutting_mesh", "material_shrinkage_percentage"} + """Settings that change the convex hull. + + If these settings change, the convex hull should be recalculated. + """ diff --git a/cura/Scene/ConvexHullNode.py b/cura/Scene/ConvexHullNode.py index da2713a522..f8a284bebe 100644 --- a/cura/Scene/ConvexHullNode.py +++ b/cura/Scene/ConvexHullNode.py @@ -18,11 +18,13 @@ if TYPE_CHECKING: class ConvexHullNode(SceneNode): shader = None # To prevent the shader from being re-built over and over again, only load it once. - ## Convex hull node is a special type of scene node that is used to display an area, to indicate the - # location an object uses on the buildplate. This area (or area's in case of one at a time printing) is - # then displayed as a transparent shadow. If the adhesion type is set to raft, the area is extruded - # to represent the raft as well. def __init__(self, node: SceneNode, hull: Optional[Polygon], thickness: float, parent: Optional[SceneNode] = None) -> None: + """Convex hull node is a special type of scene node that is used to display an area, to indicate the + + location an object uses on the buildplate. This area (or area's in case of one at a time printing) is + then displayed as a transparent shadow. If the adhesion type is set to raft, the area is extruded + to represent the raft as well. + """ super().__init__(parent) self.setCalculateBoundingBox(False) @@ -55,13 +57,21 @@ class ConvexHullNode(SceneNode): self._hull = hull if self._hull: hull_mesh_builder = MeshBuilder() - - if hull_mesh_builder.addConvexPolygonExtrusion( - self._hull.getPoints()[::-1], # bottom layer is reversed - self._mesh_height - thickness, self._mesh_height, color = self._color): - - hull_mesh = hull_mesh_builder.build() - self.setMeshData(hull_mesh) + if self._thickness == 0: + if hull_mesh_builder.addConvexPolygon( + self._hull.getPoints()[::], # bottom layer is reversed + self._mesh_height, color = self._color): + hull_mesh_builder.resetNormals() + + hull_mesh = hull_mesh_builder.build() + self.setMeshData(hull_mesh) + else: + if hull_mesh_builder.addConvexPolygonExtrusion( + self._hull.getPoints()[::-1], # bottom layer is reversed + self._mesh_height - thickness, self._mesh_height, color = self._color): + hull_mesh_builder.resetNormals() + hull_mesh = hull_mesh_builder.build() + self.setMeshData(hull_mesh) def getHull(self): return self._hull @@ -77,15 +87,15 @@ class ConvexHullNode(SceneNode): ConvexHullNode.shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader")) ConvexHullNode.shader.setUniformValue("u_diffuseColor", self._color) ConvexHullNode.shader.setUniformValue("u_opacity", 0.6) - - if self.getParent(): - if self.getMeshData() and isinstance(self._node, SceneNode) and self._node.callDecoration("getBuildPlateNumber") == Application.getInstance().getMultiBuildPlateModel().activeBuildPlate: - # The object itself (+ adhesion in one-at-a-time mode) - renderer.queueNode(self, transparent = True, shader = ConvexHullNode.shader, backface_cull = True, sort = -8) - if self._convex_hull_head_mesh: - # The full head. Rendered as a hint to the user: If this area overlaps another object A; this object - # cannot be printed after A, because the head would hit A while printing the current object - renderer.queueNode(self, shader = ConvexHullNode.shader, transparent = True, mesh = self._convex_hull_head_mesh, backface_cull = True, sort = -8) + batch = renderer.getNamedBatch("convex_hull_node") + if not batch: + batch = renderer.createRenderBatch(transparent = True, shader = ConvexHullNode.shader, backface_cull = True, sort = -8) + renderer.addRenderBatch(batch, name = "convex_hull_node") + batch.addItem(self.getWorldTransformation(copy = False), self.getMeshData()) + if self._convex_hull_head_mesh: + # The full head. Rendered as a hint to the user: If this area overlaps another object A; this object + # cannot be printed after A, because the head would hit A while printing the current object + renderer.queueNode(self, shader = ConvexHullNode.shader, transparent = True, mesh = self._convex_hull_head_mesh, backface_cull = True, sort = -8) return True @@ -95,7 +105,3 @@ class ConvexHullNode(SceneNode): convex_hull_head_builder = MeshBuilder() convex_hull_head_builder.addConvexPolygon(convex_hull_head.getPoints(), self._mesh_height - self._thickness) self._convex_hull_head_mesh = convex_hull_head_builder.build() - - if not node: - return - diff --git a/cura/Scene/CuraSceneController.py b/cura/Scene/CuraSceneController.py index 36d9e68c8f..99a6eee0e2 100644 --- a/cura/Scene/CuraSceneController.py +++ b/cura/Scene/CuraSceneController.py @@ -6,6 +6,7 @@ from PyQt5.QtWidgets import QApplication from UM.Scene.Camera import Camera from cura.UI.ObjectsModel import ObjectsModel from cura.Machines.Models.MultiBuildPlateModel import MultiBuildPlateModel +from cura.Scene.CuraSceneNode import CuraSceneNode from UM.Application import Application from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator @@ -43,6 +44,26 @@ class CuraSceneController(QObject): self._change_timer.start() def updateMaxBuildPlate(self, *args): + global_stack = Application.getInstance().getGlobalContainerStack() + if global_stack: + scene_has_support_meshes = self._sceneHasSupportMeshes() # TODO: see if this can be cached + + if scene_has_support_meshes != global_stack.getProperty("support_meshes_present", "value"): + # Adjust the setting without having the setting value in an InstanceContainer + setting_definitions = global_stack.definition.findDefinitions(key="support_meshes_present") + if setting_definitions: + # Recreate the setting definition because the default_value is readonly + definition_dict = setting_definitions[0].serialize_to_dict() + definition_dict["enabled"] = False # The enabled property has a value that would need to be evaluated + definition_dict["default_value"] = scene_has_support_meshes + relations = setting_definitions[0].relations # Relations are wiped when deserializing from a dict + setting_definitions[0].deserialize(definition_dict) + + # Restore relations and notify them that the setting has changed + for relation in relations: + setting_definitions[0].relations.append(relation) + global_stack.propertyChanged.emit(relation.target.key, "enabled") + max_build_plate = self._calcMaxBuildPlate() changed = False if max_build_plate != self._max_build_plate: @@ -72,9 +93,19 @@ class CuraSceneController(QObject): max_build_plate = max(build_plate_number, max_build_plate) return max_build_plate - ## Either select or deselect an item + def _sceneHasSupportMeshes(self): + root = Application.getInstance().getController().getScene().getRoot() + for node in root.getAllChildren(): + if isinstance(node, CuraSceneNode): + per_mesh_stack = node.callDecoration("getStack") + if per_mesh_stack and per_mesh_stack.getProperty("support_mesh", "value"): + return True + return False + @pyqtSlot(int) def changeSelection(self, index): + """Either select or deselect an item""" + modifiers = QApplication.keyboardModifiers() ctrl_is_active = modifiers & Qt.ControlModifier shift_is_active = modifiers & Qt.ShiftModifier diff --git a/cura/Scene/CuraSceneNode.py b/cura/Scene/CuraSceneNode.py index eb609def5a..93a1511681 100644 --- a/cura/Scene/CuraSceneNode.py +++ b/cura/Scene/CuraSceneNode.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from copy import deepcopy @@ -15,9 +15,11 @@ from cura.Settings.ExtruderStack import ExtruderStack # For typing. from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator # For per-object settings. -## Scene nodes that are models are only seen when selecting the corresponding build plate -# Note that many other nodes can just be UM SceneNode objects. class CuraSceneNode(SceneNode): + """Scene nodes that are models are only seen when selecting the corresponding build plate + + Note that many other nodes can just be UM SceneNode objects. + """ def __init__(self, parent: Optional["SceneNode"] = None, visible: bool = True, name: str = "", no_setting_override: bool = False) -> None: super().__init__(parent = parent, visible = visible, name = name) if not no_setting_override: @@ -36,15 +38,23 @@ class CuraSceneNode(SceneNode): def isSelectable(self) -> bool: return super().isSelectable() and self.callDecoration("getBuildPlateNumber") == cura.CuraApplication.CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate - ## Get the extruder used to print this node. If there is no active node, then the extruder in position zero is returned - # TODO The best way to do it is by adding the setActiveExtruder decorator to every node when is loaded + def isSupportMesh(self) -> bool: + per_mesh_stack = self.callDecoration("getStack") + if not per_mesh_stack: + return False + return per_mesh_stack.getProperty("support_mesh", "value") + def getPrintingExtruder(self) -> Optional[ExtruderStack]: + """Get the extruder used to print this node. If there is no active node, then the extruder in position zero is returned + + TODO The best way to do it is by adding the setActiveExtruder decorator to every node when is loaded + """ global_container_stack = Application.getInstance().getGlobalContainerStack() if global_container_stack is None: return None per_mesh_stack = self.callDecoration("getStack") - extruders = list(global_container_stack.extruders.values()) + extruders = global_container_stack.extruderList # Use the support extruder instead of the active extruder if this is a support_mesh if per_mesh_stack: @@ -69,8 +79,9 @@ class CuraSceneNode(SceneNode): # This point should never be reached return None - ## Return the color of the material used to print this model def getDiffuseColor(self) -> List[float]: + """Return the color of the material used to print this model""" + printing_extruder = self.getPrintingExtruder() material_color = "#808080" # Fallback color @@ -86,8 +97,9 @@ class CuraSceneNode(SceneNode): 1.0 ] - ## Return if any area collides with the convex hull of this scene node def collidesWithAreas(self, areas: List[Polygon]) -> bool: + """Return if any area collides with the convex hull of this scene node""" + convex_hull = self.callDecoration("getPrintingArea") if convex_hull: if not convex_hull.isValid(): @@ -101,14 +113,15 @@ class CuraSceneNode(SceneNode): return True return False - ## Override of SceneNode._calculateAABB to exclude non-printing-meshes from bounding box def _calculateAABB(self) -> None: + """Override of SceneNode._calculateAABB to exclude non-printing-meshes from bounding box""" + self._aabb = None if self._mesh_data: - self._aabb = self._mesh_data.getExtents(self.getWorldTransformation()) - else: # If there is no mesh_data, use a boundingbox that encompasses the local (0,0,0) + self._aabb = self._mesh_data.getExtents(self.getWorldTransformation(copy = False)) + else: # If there is no mesh_data, use a bounding box that encompasses the local (0,0,0) position = self.getWorldPosition() - self._aabb = AxisAlignedBox(minimum=position, maximum=position) + self._aabb = AxisAlignedBox(minimum = position, maximum = position) for child in self.getAllChildren(): if child.callDecoration("isNonPrintingMesh"): @@ -122,10 +135,11 @@ class CuraSceneNode(SceneNode): else: self._aabb = self._aabb + child.getBoundingBox() - ## Taken from SceneNode, but replaced SceneNode with CuraSceneNode def __deepcopy__(self, memo: Dict[int, object]) -> "CuraSceneNode": + """Taken from SceneNode, but replaced SceneNode with CuraSceneNode""" + copy = CuraSceneNode(no_setting_override = True) # Setting override will be added later - copy.setTransformation(self.getLocalTransformation()) + copy.setTransformation(self.getLocalTransformation(copy= False)) copy.setMeshData(self._mesh_data) copy.setVisible(cast(bool, deepcopy(self._visible, memo))) copy._selectable = cast(bool, deepcopy(self._selectable, memo)) diff --git a/cura/Scene/SliceableObjectDecorator.py b/cura/Scene/SliceableObjectDecorator.py index 982a38d667..ad51f7d755 100644 --- a/cura/Scene/SliceableObjectDecorator.py +++ b/cura/Scene/SliceableObjectDecorator.py @@ -4,7 +4,7 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator class SliceableObjectDecorator(SceneNodeDecorator): def __init__(self) -> None: super().__init__() - + def isSliceable(self) -> bool: return True diff --git a/cura/Scene/ZOffsetDecorator.py b/cura/Scene/ZOffsetDecorator.py index b35b17a412..1f1f5a9b1f 100644 --- a/cura/Scene/ZOffsetDecorator.py +++ b/cura/Scene/ZOffsetDecorator.py @@ -1,8 +1,9 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator -## A decorator that stores the amount an object has been moved below the platform. class ZOffsetDecorator(SceneNodeDecorator): + """A decorator that stores the amount an object has been moved below the platform.""" + def __init__(self) -> None: super().__init__() self._z_offset = 0. diff --git a/cura/Settings/ContainerManager.py b/cura/Settings/ContainerManager.py index 92f06929d2..08fdf707cf 100644 --- a/cura/Settings/ContainerManager.py +++ b/cura/Settings/ContainerManager.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import os @@ -33,12 +33,14 @@ if TYPE_CHECKING: catalog = i18nCatalog("cura") -## Manager class that contains common actions to deal with containers in Cura. -# -# This is primarily intended as a class to be able to perform certain actions -# from within QML. We want to be able to trigger things like removing a container -# when a certain action happens. This can be done through this class. class ContainerManager(QObject): + """Manager class that contains common actions to deal with containers in Cura. + + This is primarily intended as a class to be able to perform certain actions + from within QML. We want to be able to trigger things like removing a container + when a certain action happens. This can be done through this class. + """ + def __init__(self, application: "CuraApplication") -> None: if ContainerManager.__instance is not None: @@ -67,21 +69,23 @@ class ContainerManager(QObject): return "" return str(result) - ## Set a metadata entry of the specified container. - # - # This will set the specified entry of the container's metadata to the specified - # value. Note that entries containing dictionaries can have their entries changed - # by using "/" as a separator. For example, to change an entry "foo" in a - # dictionary entry "bar", you can specify "bar/foo" as entry name. - # - # \param container_node \type{ContainerNode} - # \param entry_name \type{str} The name of the metadata entry to change. - # \param entry_value The new value of the entry. - # - # TODO: This is ONLY used by MaterialView for material containers. Maybe refactor this. - # Update: In order for QML to use objects and sub objects, those (sub) objects must all be QObject. Is that what we want? @pyqtSlot("QVariant", str, str) def setContainerMetaDataEntry(self, container_node: "ContainerNode", entry_name: str, entry_value: str) -> bool: + """Set a metadata entry of the specified container. + + This will set the specified entry of the container's metadata to the specified + value. Note that entries containing dictionaries can have their entries changed + by using "/" as a separator. For example, to change an entry "foo" in a + dictionary entry "bar", you can specify "bar/foo" as entry name. + + :param container_node: :type{ContainerNode} + :param entry_name: :type{str} The name of the metadata entry to change. + :param entry_value: The new value of the entry. + + TODO: This is ONLY used by MaterialView for material containers. Maybe refactor this. + Update: In order for QML to use objects and sub objects, those (sub) objects must all be QObject. Is that what we want? + """ + if container_node.container is None: Logger.log("w", "Container node {0} doesn't have a container.".format(container_node.container_id)) return False @@ -118,24 +122,28 @@ class ContainerManager(QObject): root_material.setMetaDataEntry(entry_name, entry_value) if sub_item_changed: #If it was only a sub-item that has changed then the setMetaDataEntry won't correctly notice that something changed, and we must manually signal that the metadata changed. root_material.metaDataChanged.emit(root_material) + + cura.CuraApplication.CuraApplication.getInstance().getMachineManager().updateUponMaterialMetadataChange() return True @pyqtSlot(str, result = str) def makeUniqueName(self, original_name: str) -> str: return cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry().uniqueName(original_name) - ## Get a list of string that can be used as name filters for a Qt File Dialog - # - # This will go through the list of available container types and generate a list of strings - # out of that. The strings are formatted as "description (*.extension)" and can be directly - # passed to a nameFilters property of a Qt File Dialog. - # - # \param type_name Which types of containers to list. These types correspond to the "type" - # key of the plugin metadata. - # - # \return A string list with name filters. @pyqtSlot(str, result = "QStringList") def getContainerNameFilters(self, type_name: str) -> List[str]: + """Get a list of string that can be used as name filters for a Qt File Dialog + + This will go through the list of available container types and generate a list of strings + out of that. The strings are formatted as "description (*.extension)" and can be directly + passed to a nameFilters property of a Qt File Dialog. + + :param type_name: Which types of containers to list. These types correspond to the "type" + key of the plugin metadata. + + :return: A string list with name filters. + """ + if not self._container_name_filters: self._updateContainerNameFilters() @@ -147,17 +155,18 @@ class ContainerManager(QObject): filters.append("All Files (*)") return filters - ## Export a container to a file - # - # \param container_id The ID of the container to export - # \param file_type The type of file to save as. Should be in the form of "description (*.extension, *.ext)" - # \param file_url_or_string The URL where to save the file. - # - # \return A dictionary containing a key "status" with a status code and a key "message" with a message - # explaining the status. - # The status code can be one of "error", "cancelled", "success" @pyqtSlot(str, str, QUrl, result = "QVariantMap") def exportContainer(self, container_id: str, file_type: str, file_url_or_string: Union[QUrl, str]) -> Dict[str, str]: + """Export a container to a file + + :param container_id: The ID of the container to export + :param file_type: The type of file to save as. Should be in the form of "description (*.extension, *.ext)" + :param file_url_or_string: The URL where to save the file. + + :return: A dictionary containing a key "status" with a status code and a key "message" with a message + explaining the status. The status code can be one of "error", "cancelled", "success" + """ + if not container_id or not file_type or not file_url_or_string: return {"status": "error", "message": "Invalid arguments"} @@ -206,19 +215,24 @@ class ContainerManager(QObject): if contents is None: return {"status": "error", "message": "Serialization returned None. Unable to write to file"} - with SaveFile(file_url, "w") as f: - f.write(contents) + try: + with SaveFile(file_url, "w") as f: + f.write(contents) + except OSError: + return {"status": "error", "message": "Unable to write to this location.", "path": file_url} return {"status": "success", "message": "Successfully exported container", "path": file_url} - ## Imports a profile from a file - # - # \param file_url A URL that points to the file to import. - # - # \return \type{Dict} dict with a 'status' key containing the string 'success' or 'error', and a 'message' key - # containing a message for the user @pyqtSlot(QUrl, result = "QVariantMap") def importMaterialContainer(self, file_url_or_string: Union[QUrl, str]) -> Dict[str, str]: + """Imports a profile from a file + + :param file_url: A URL that points to the file to import. + + :return: :type{Dict} dict with a 'status' key containing the string 'success' or 'error', and a 'message' key + containing a message for the user + """ + if not file_url_or_string: return {"status": "error", "message": "Invalid path"} @@ -239,6 +253,8 @@ class ContainerManager(QObject): container_type = container_registry.getContainerForMimeType(mime_type) if not container_type: return {"status": "error", "message": "Could not find a container to handle the specified file."} + if not issubclass(container_type, InstanceContainer): + return {"status": "error", "message": "This is not a material container, but another type of file."} container_id = urllib.parse.unquote_plus(mime_type.stripExtension(os.path.basename(file_url))) container_id = container_registry.uniqueName(container_id) @@ -261,14 +277,16 @@ class ContainerManager(QObject): return {"status": "success", "message": "Successfully imported container {0}".format(container.getName())} - ## Update the current active quality changes container with the settings from the user container. - # - # This will go through the active global stack and all active extruder stacks and merge the changes from the user - # container into the quality_changes container. After that, the user container is cleared. - # - # \return \type{bool} True if successful, False if not. @pyqtSlot(result = bool) def updateQualityChanges(self) -> bool: + """Update the current active quality changes container with the settings from the user container. + + This will go through the active global stack and all active extruder stacks and merge the changes from the user + container into the quality_changes container. After that, the user container is cleared. + + :return: :type{bool} True if successful, False if not. + """ + application = cura.CuraApplication.CuraApplication.getInstance() global_stack = application.getMachineManager().activeMachine if not global_stack: @@ -278,7 +296,7 @@ class ContainerManager(QObject): current_quality_changes_name = global_stack.qualityChanges.getName() current_quality_type = global_stack.quality.getMetaDataEntry("quality_type") - extruder_stacks = list(global_stack.extruders.values()) + extruder_stacks = global_stack.extruderList container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() machine_definition_id = ContainerTree.getInstance().machines[global_stack.definition.getId()].quality_definition for stack in [global_stack] + extruder_stacks: @@ -308,9 +326,10 @@ class ContainerManager(QObject): return True - ## Clear the top-most (user) containers of the active stacks. @pyqtSlot() def clearUserContainers(self) -> None: + """Clear the top-most (user) containers of the active stacks.""" + machine_manager = cura.CuraApplication.CuraApplication.getInstance().getMachineManager() machine_manager.blurSettings.emit() @@ -318,8 +337,7 @@ class ContainerManager(QObject): # Go through global and extruder stacks and clear their topmost container (the user settings). global_stack = machine_manager.activeMachine - extruder_stacks = list(global_stack.extruders.values()) - for stack in [global_stack] + extruder_stacks: + for stack in [global_stack] + global_stack.extruderList: container = stack.userChanges container.clear() send_emits_containers.append(container) @@ -327,28 +345,34 @@ class ContainerManager(QObject): # user changes are possibly added to make the current setup match the current enabled extruders machine_manager.correctExtruderSettings() + # The Print Sequence should be changed to match the current setup + machine_manager.correctPrintSequence() + for container in send_emits_containers: container.sendPostponedEmits() - ## Get a list of materials that have the same GUID as the reference material - # - # \param material_node The node representing the material for which to get - # the same GUID. - # \param exclude_self Whether to include the name of the material you - # provided. - # \return A list of names of materials with the same GUID. @pyqtSlot("QVariant", bool, result = "QStringList") def getLinkedMaterials(self, material_node: "MaterialNode", exclude_self: bool = False) -> List[str]: + """Get a list of materials that have the same GUID as the reference material + + :param material_node: The node representing the material for which to get + the same GUID. + :param exclude_self: Whether to include the name of the material you provided. + :return: A list of names of materials with the same GUID. + """ + same_guid = ContainerRegistry.getInstance().findInstanceContainersMetadata(GUID = material_node.guid) if exclude_self: return list({meta["name"] for meta in same_guid if meta["base_file"] != material_node.base_file}) else: return list({meta["name"] for meta in same_guid}) - ## Unlink a material from all other materials by creating a new GUID - # \param material_id \type{str} the id of the material to create a new GUID for. @pyqtSlot("QVariant") def unlinkMaterial(self, material_node: "MaterialNode") -> None: + """Unlink a material from all other materials by creating a new GUID + + :param material_id: :type{str} the id of the material to create a new GUID for. + """ # Get the material group if material_node.container is None: # Failed to lazy-load this container. return @@ -423,9 +447,10 @@ class ContainerManager(QObject): name_filter = "{0} ({1})".format(mime_type.comment, suffix_list) self._container_name_filters[name_filter] = entry - ## Import single profile, file_url does not have to end with curaprofile @pyqtSlot(QUrl, result = "QVariantMap") def importProfile(self, file_url: QUrl) -> Dict[str, str]: + """Import single profile, file_url does not have to end with curaprofile""" + if not file_url.isValid(): return {"status": "error", "message": catalog.i18nc("@info:status", "Invalid file URL:") + " " + str(file_url)} path = file_url.toLocalFile() diff --git a/cura/Settings/CuraContainerRegistry.py b/cura/Settings/CuraContainerRegistry.py index 0ef09a1fac..24b7436bad 100644 --- a/cura/Settings/CuraContainerRegistry.py +++ b/cura/Settings/CuraContainerRegistry.py @@ -1,765 +1,837 @@ -# Copyright (c) 2019 Ultimaker B.V.
-# Cura is released under the terms of the LGPLv3 or higher.
-
-import os
-import re
-import configparser
-
-from typing import Any, cast, Dict, Optional, List, Union
-from PyQt5.QtWidgets import QMessageBox
-
-from UM.Decorators import override
-from UM.Settings.ContainerFormatError import ContainerFormatError
-from UM.Settings.Interfaces import ContainerInterface
-from UM.Settings.ContainerRegistry import ContainerRegistry
-from UM.Settings.ContainerStack import ContainerStack
-from UM.Settings.InstanceContainer import InstanceContainer
-from UM.Settings.SettingInstance import SettingInstance
-from UM.Logger import Logger
-from UM.Message import Message
-from UM.Platform import Platform
-from UM.PluginRegistry import PluginRegistry # For getting the possible profile writers to write with.
-from UM.Resources import Resources
-from UM.Util import parseBool
-from cura.ReaderWriters.ProfileWriter import ProfileWriter
-
-from . import ExtruderStack
-from . import GlobalStack
-
-import cura.CuraApplication
-from cura.Settings.cura_empty_instance_containers import empty_quality_container
-from cura.Machines.ContainerTree import ContainerTree
-from cura.ReaderWriters.ProfileReader import NoProfileException, ProfileReader
-
-from UM.i18n import i18nCatalog
-catalog = i18nCatalog("cura")
-
-
-class CuraContainerRegistry(ContainerRegistry):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # We don't have all the machines loaded in the beginning, so in order to add the missing extruder stack
- # for single extrusion machines, we subscribe to the containerAdded signal, and whenever a global stack
- # is added, we check to see if an extruder stack needs to be added.
- self.containerAdded.connect(self._onContainerAdded)
-
- ## Overridden from ContainerRegistry
- #
- # Adds a container to the registry.
- #
- # This will also try to convert a ContainerStack to either Extruder or
- # Global stack based on metadata information.
- @override(ContainerRegistry)
- def addContainer(self, container: ContainerInterface) -> None:
- # Note: Intentional check with type() because we want to ignore subclasses
- if type(container) == ContainerStack:
- container = self._convertContainerStack(cast(ContainerStack, container))
-
- if isinstance(container, InstanceContainer) and type(container) != type(self.getEmptyInstanceContainer()):
- # Check against setting version of the definition.
- required_setting_version = cura.CuraApplication.CuraApplication.SettingVersion
- actual_setting_version = int(container.getMetaDataEntry("setting_version", default = 0))
- if required_setting_version != actual_setting_version:
- Logger.log("w", "Instance container {container_id} is outdated. Its setting version is {actual_setting_version} but it should be {required_setting_version}.".format(container_id = container.getId(), actual_setting_version = actual_setting_version, required_setting_version = required_setting_version))
- return # Don't add.
-
- super().addContainer(container)
-
- ## Create a name that is not empty and unique
- # \param container_type \type{string} Type of the container (machine, quality, ...)
- # \param current_name \type{} Current name of the container, which may be an acceptable option
- # \param new_name \type{string} Base name, which may not be unique
- # \param fallback_name \type{string} Name to use when (stripped) new_name is empty
- # \return \type{string} Name that is unique for the specified type and name/id
- def createUniqueName(self, container_type: str, current_name: str, new_name: str, fallback_name: str) -> str:
- new_name = new_name.strip()
- num_check = re.compile(r"(.*?)\s*#\d+$").match(new_name)
- if num_check:
- new_name = num_check.group(1)
- if new_name == "":
- new_name = fallback_name
-
- unique_name = new_name
- i = 1
- # In case we are renaming, the current name of the container is also a valid end-result
- while self._containerExists(container_type, unique_name) and unique_name != current_name:
- i += 1
- unique_name = "%s #%d" % (new_name, i)
-
- return unique_name
-
- ## Check if a container with of a certain type and a certain name or id exists
- # Both the id and the name are checked, because they may not be the same and it is better if they are both unique
- # \param container_type \type{string} Type of the container (machine, quality, ...)
- # \param container_name \type{string} Name to check
- def _containerExists(self, container_type: str, container_name: str):
- container_class = ContainerStack if container_type == "machine" else InstanceContainer
-
- return self.findContainersMetadata(container_type = container_class, id = container_name, type = container_type, ignore_case = True) or \
- self.findContainersMetadata(container_type = container_class, name = container_name, type = container_type)
-
- ## Exports an profile to a file
- #
- # \param container_list \type{list} the containers to export. This is not
- # necessarily in any order!
- # \param file_name \type{str} the full path and filename to export to.
- # \param file_type \type{str} the file type with the format "<description> (*.<extension>)"
- # \return True if the export succeeded, false otherwise.
- def exportQualityProfile(self, container_list: List[InstanceContainer], file_name: str, file_type: str) -> bool:
- # Parse the fileType to deduce what plugin can save the file format.
- # fileType has the format "<description> (*.<extension>)"
- split = file_type.rfind(" (*.") # Find where the description ends and the extension starts.
- if split < 0: # Not found. Invalid format.
- Logger.log("e", "Invalid file format identifier %s", file_type)
- return False
- description = file_type[:split]
- extension = file_type[split + 4:-1] # Leave out the " (*." and ")".
- if not file_name.endswith("." + extension): # Auto-fill the extension if the user did not provide any.
- file_name += "." + extension
-
- # On Windows, QML FileDialog properly asks for overwrite confirm, but not on other platforms, so handle those ourself.
- if not Platform.isWindows():
- if os.path.exists(file_name):
- result = QMessageBox.question(None, catalog.i18nc("@title:window", "File Already Exists"),
- catalog.i18nc("@label Don't translate the XML tag <filename>!", "The file <filename>{0}</filename> already exists. Are you sure you want to overwrite it?").format(file_name))
- if result == QMessageBox.No:
- return False
-
- profile_writer = self._findProfileWriter(extension, description)
- try:
- if profile_writer is None:
- raise Exception("Unable to find a profile writer")
- success = profile_writer.write(file_name, container_list)
- except Exception as e:
- Logger.log("e", "Failed to export profile to %s: %s", file_name, str(e))
- m = Message(catalog.i18nc("@info:status Don't translate the XML tags <filename> or <message>!", "Failed to export profile to <filename>{0}</filename>: <message>{1}</message>", file_name, str(e)),
- lifetime = 0,
- title = catalog.i18nc("@info:title", "Error"))
- m.show()
- return False
- if not success:
- Logger.log("w", "Failed to export profile to %s: Writer plugin reported failure.", file_name)
- m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Failed to export profile to <filename>{0}</filename>: Writer plugin reported failure.", file_name),
- lifetime = 0,
- title = catalog.i18nc("@info:title", "Error"))
- m.show()
- return False
- m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Exported profile to <filename>{0}</filename>", file_name),
- title = catalog.i18nc("@info:title", "Export succeeded"))
- m.show()
- return True
-
- ## Gets the plugin object matching the criteria
- # \param extension
- # \param description
- # \return The plugin object matching the given extension and description.
- def _findProfileWriter(self, extension: str, description: str) -> Optional[ProfileWriter]:
- plugin_registry = PluginRegistry.getInstance()
- for plugin_id, meta_data in self._getIOPlugins("profile_writer"):
- for supported_type in meta_data["profile_writer"]: # All file types this plugin can supposedly write.
- supported_extension = supported_type.get("extension", None)
- if supported_extension == extension: # This plugin supports a file type with the same extension.
- supported_description = supported_type.get("description", None)
- if supported_description == description: # The description is also identical. Assume it's the same file type.
- return cast(ProfileWriter, plugin_registry.getPluginObject(plugin_id))
- return None
-
- ## Imports a profile from a file
- #
- # \param file_name The full path and filename of the profile to import.
- # \return Dict with a 'status' key containing the string 'ok' or 'error',
- # and a 'message' key containing a message for the user.
- def importProfile(self, file_name: str) -> Dict[str, str]:
- Logger.log("d", "Attempting to import profile %s", file_name)
- if not file_name:
- return { "status": "error", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "Failed to import profile from <filename>{0}</filename>: {1}", file_name, "Invalid path")}
-
- global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
- if not global_stack:
- return {"status": "error", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "Can't import profile from <filename>{0}</filename> before a printer is added.", file_name)}
- container_tree = ContainerTree.getInstance()
-
- machine_extruders = []
- for position in sorted(global_stack.extruders):
- machine_extruders.append(global_stack.extruders[position])
-
- plugin_registry = PluginRegistry.getInstance()
- extension = file_name.split(".")[-1]
-
- for plugin_id, meta_data in self._getIOPlugins("profile_reader"):
- if meta_data["profile_reader"][0]["extension"] != extension:
- continue
- profile_reader = cast(ProfileReader, plugin_registry.getPluginObject(plugin_id))
- try:
- profile_or_list = profile_reader.read(file_name) # Try to open the file with the profile reader.
- except NoProfileException:
- return { "status": "ok", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "No custom profile to import in file <filename>{0}</filename>", file_name)}
- except Exception as e:
- # Note that this will fail quickly. That is, if any profile reader throws an exception, it will stop reading. It will only continue reading if the reader returned None.
- Logger.log("e", "Failed to import profile from %s: %s while using profile reader. Got exception %s", file_name, profile_reader.getPluginId(), str(e))
- return { "status": "error", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "Failed to import profile from <filename>{0}</filename>:", file_name) + "\n<message>" + str(e) + "</message>"}
-
- if profile_or_list:
- # Ensure it is always a list of profiles
- if not isinstance(profile_or_list, list):
- profile_or_list = [profile_or_list]
-
- # First check if this profile is suitable for this machine
- global_profile = None
- extruder_profiles = []
- if len(profile_or_list) == 1:
- global_profile = profile_or_list[0]
- else:
- for profile in profile_or_list:
- if not profile.getMetaDataEntry("position"):
- global_profile = profile
- else:
- extruder_profiles.append(profile)
- extruder_profiles = sorted(extruder_profiles, key = lambda x: int(x.getMetaDataEntry("position")))
- profile_or_list = [global_profile] + extruder_profiles
-
- if not global_profile:
- Logger.log("e", "Incorrect profile [%s]. Could not find global profile", file_name)
- return { "status": "error",
- "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "This profile <filename>{0}</filename> contains incorrect data, could not import it.", file_name)}
- profile_definition = global_profile.getMetaDataEntry("definition")
-
- # Make sure we have a profile_definition in the file:
- if profile_definition is None:
- break
- machine_definitions = self.findContainers(id = profile_definition)
- if not machine_definitions:
- Logger.log("e", "Incorrect profile [%s]. Unknown machine type [%s]", file_name, profile_definition)
- return {"status": "error",
- "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "This profile <filename>{0}</filename> contains incorrect data, could not import it.", file_name)
- }
- machine_definition = machine_definitions[0]
-
- # Get the expected machine definition.
- # i.e.: We expect gcode for a UM2 Extended to be defined as normal UM2 gcode...
- has_machine_quality = parseBool(machine_definition.getMetaDataEntry("has_machine_quality", "false"))
- profile_definition = machine_definition.getMetaDataEntry("quality_definition", machine_definition.getId()) if has_machine_quality else "fdmprinter"
- expected_machine_definition = container_tree.machines[global_stack.definition.getId()].quality_definition
-
- # And check if the profile_definition matches either one (showing error if not):
- if profile_definition != expected_machine_definition:
- Logger.log("d", "Profile {file_name} is for machine {profile_definition}, but the current active machine is {expected_machine_definition}. Changing profile's definition.".format(file_name = file_name, profile_definition = profile_definition, expected_machine_definition = expected_machine_definition))
- global_profile.setMetaDataEntry("definition", expected_machine_definition)
- for extruder_profile in extruder_profiles:
- extruder_profile.setMetaDataEntry("definition", expected_machine_definition)
-
- quality_name = global_profile.getName()
- quality_type = global_profile.getMetaDataEntry("quality_type")
-
- name_seed = os.path.splitext(os.path.basename(file_name))[0]
- new_name = self.uniqueName(name_seed)
-
- # Ensure it is always a list of profiles
- if type(profile_or_list) is not list:
- profile_or_list = [profile_or_list]
-
- # Make sure that there are also extruder stacks' quality_changes, not just one for the global stack
- if len(profile_or_list) == 1:
- global_profile = profile_or_list[0]
- extruder_profiles = []
- for idx, extruder in enumerate(global_stack.extruders.values()):
- profile_id = ContainerRegistry.getInstance().uniqueName(global_stack.getId() + "_extruder_" + str(idx + 1))
- profile = InstanceContainer(profile_id)
- profile.setName(quality_name)
- profile.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.SettingVersion)
- profile.setMetaDataEntry("type", "quality_changes")
- profile.setMetaDataEntry("definition", expected_machine_definition)
- profile.setMetaDataEntry("quality_type", quality_type)
- profile.setDirty(True)
- if idx == 0:
- # Move all per-extruder settings to the first extruder's quality_changes
- for qc_setting_key in global_profile.getAllKeys():
- settable_per_extruder = global_stack.getProperty(qc_setting_key, "settable_per_extruder")
- if settable_per_extruder:
- setting_value = global_profile.getProperty(qc_setting_key, "value")
-
- setting_definition = global_stack.getSettingDefinition(qc_setting_key)
- if setting_definition is not None:
- new_instance = SettingInstance(setting_definition, profile)
- new_instance.setProperty("value", setting_value)
- new_instance.resetState() # Ensure that the state is not seen as a user state.
- profile.addInstance(new_instance)
- profile.setDirty(True)
-
- global_profile.removeInstance(qc_setting_key, postpone_emit = True)
- extruder_profiles.append(profile)
-
- for profile in extruder_profiles:
- profile_or_list.append(profile)
-
- # Import all profiles
- profile_ids_added = [] # type: List[str]
- for profile_index, profile in enumerate(profile_or_list):
- if profile_index == 0:
- # This is assumed to be the global profile
- profile_id = (cast(ContainerInterface, global_stack.getBottom()).getId() + "_" + name_seed).lower().replace(" ", "_")
-
- elif profile_index < len(machine_extruders) + 1:
- # This is assumed to be an extruder profile
- extruder_id = machine_extruders[profile_index - 1].definition.getId()
- extruder_position = str(profile_index - 1)
- if not profile.getMetaDataEntry("position"):
- profile.setMetaDataEntry("position", extruder_position)
- else:
- profile.setMetaDataEntry("position", extruder_position)
- profile_id = (extruder_id + "_" + name_seed).lower().replace(" ", "_")
-
- else: # More extruders in the imported file than in the machine.
- continue # Delete the additional profiles.
-
- result = self._configureProfile(profile, profile_id, new_name, expected_machine_definition)
- if result is not None:
- # Remove any profiles that did got added.
- for profile_id in profile_ids_added:
- self.removeContainer(profile_id)
-
- return {"status": "error", "message": catalog.i18nc(
- "@info:status Don't translate the XML tag <filename>!",
- "Failed to import profile from <filename>{0}</filename>:",
- file_name) + " " + result}
- profile_ids_added.append(profile.getId())
- return {"status": "ok", "message": catalog.i18nc("@info:status", "Successfully imported profile {0}", profile_or_list[0].getName())}
-
- # This message is throw when the profile reader doesn't find any profile in the file
- return {"status": "error", "message": catalog.i18nc("@info:status", "File {0} does not contain any valid profile.", file_name)}
-
- # If it hasn't returned by now, none of the plugins loaded the profile successfully.
- return {"status": "error", "message": catalog.i18nc("@info:status", "Profile {0} has an unknown file type or is corrupted.", file_name)}
-
- @override(ContainerRegistry)
- def load(self) -> None:
- super().load()
- self._registerSingleExtrusionMachinesExtruderStacks()
- self._connectUpgradedExtruderStacksToMachines()
-
- ## Check if the metadata for a container is okay before adding it.
- #
- # This overrides the one from UM.Settings.ContainerRegistry because we
- # also require that the setting_version is correct.
- @override(ContainerRegistry)
- def _isMetadataValid(self, metadata: Optional[Dict[str, Any]]) -> bool:
- if metadata is None:
- return False
- if "setting_version" not in metadata:
- return False
- try:
- if int(metadata["setting_version"]) != cura.CuraApplication.CuraApplication.SettingVersion:
- return False
- except ValueError: #Not parsable as int.
- return False
- return True
-
- ## Update an imported profile to match the current machine configuration.
- #
- # \param profile The profile to configure.
- # \param id_seed The base ID for the profile. May be changed so it does not conflict with existing containers.
- # \param new_name The new name for the profile.
- #
- # \return None if configuring was successful or an error message if an error occurred.
- def _configureProfile(self, profile: InstanceContainer, id_seed: str, new_name: str, machine_definition_id: str) -> Optional[str]:
- profile.setDirty(True) # Ensure the profiles are correctly saved
-
- new_id = self.createUniqueName("quality_changes", "", id_seed, catalog.i18nc("@label", "Custom profile"))
- profile.setMetaDataEntry("id", new_id)
- profile.setName(new_name)
-
- # Set the unique Id to the profile, so it's generating a new one even if the user imports the same profile
- # It also solves an issue with importing profiles from G-Codes
- profile.setMetaDataEntry("id", new_id)
- profile.setMetaDataEntry("definition", machine_definition_id)
-
- if "type" in profile.getMetaData():
- profile.setMetaDataEntry("type", "quality_changes")
- else:
- profile.setMetaDataEntry("type", "quality_changes")
-
- quality_type = profile.getMetaDataEntry("quality_type")
- if not quality_type:
- return catalog.i18nc("@info:status", "Profile is missing a quality type.")
-
- global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
- if global_stack is None:
- return None
- definition_id = ContainerTree.getInstance().machines[global_stack.definition.getId()].quality_definition
- profile.setDefinition(definition_id)
-
- # Check to make sure the imported profile actually makes sense in context of the current configuration.
- # This prevents issues where importing a "draft" profile for a machine without "draft" qualities would report as
- # successfully imported but then fail to show up.
- quality_group_dict = ContainerTree.getInstance().getCurrentQualityGroups()
- # "not_supported" profiles can be imported.
- if quality_type != empty_quality_container.getMetaDataEntry("quality_type") and quality_type not in quality_group_dict:
- return catalog.i18nc("@info:status", "Could not find a quality type {0} for the current configuration.", quality_type)
-
- ContainerRegistry.getInstance().addContainer(profile)
-
- return None
-
- @override(ContainerRegistry)
- def saveDirtyContainers(self) -> None:
- # Lock file for "more" atomically loading and saving to/from config dir.
- with self.lockFile():
- # Save base files first
- for instance in self.findDirtyContainers(container_type=InstanceContainer):
- if instance.getMetaDataEntry("removed"):
- continue
- if instance.getId() == instance.getMetaData().get("base_file"):
- self.saveContainer(instance)
-
- for instance in self.findDirtyContainers(container_type=InstanceContainer):
- if instance.getMetaDataEntry("removed"):
- continue
- self.saveContainer(instance)
-
- for stack in self.findContainerStacks():
- self.saveContainer(stack)
-
- ## Gets a list of profile writer plugins
- # \return List of tuples of (plugin_id, meta_data).
- def _getIOPlugins(self, io_type):
- plugin_registry = PluginRegistry.getInstance()
- active_plugin_ids = plugin_registry.getActivePlugins()
-
- result = []
- for plugin_id in active_plugin_ids:
- meta_data = plugin_registry.getMetaData(plugin_id)
- if io_type in meta_data:
- result.append( (plugin_id, meta_data) )
- return result
-
- ## Convert an "old-style" pure ContainerStack to either an Extruder or Global stack.
- def _convertContainerStack(self, container: ContainerStack) -> Union[ExtruderStack.ExtruderStack, GlobalStack.GlobalStack]:
- assert type(container) == ContainerStack
-
- container_type = container.getMetaDataEntry("type")
- if container_type not in ("extruder_train", "machine"):
- # It is not an extruder or machine, so do nothing with the stack
- return container
-
- Logger.log("d", "Converting ContainerStack {stack} to {type}", stack = container.getId(), type = container_type)
-
- if container_type == "extruder_train":
- new_stack = ExtruderStack.ExtruderStack(container.getId())
- else:
- new_stack = GlobalStack.GlobalStack(container.getId())
-
- container_contents = container.serialize()
- new_stack.deserialize(container_contents)
-
- # Delete the old configuration file so we do not get double stacks
- if os.path.isfile(container.getPath()):
- os.remove(container.getPath())
-
- return new_stack
-
- def _registerSingleExtrusionMachinesExtruderStacks(self) -> None:
- machines = self.findContainerStacks(type = "machine", machine_extruder_trains = {"0": "fdmextruder"})
- for machine in machines:
- extruder_stacks = self.findContainerStacks(type = "extruder_train", machine = machine.getId())
- if not extruder_stacks:
- self.addExtruderStackForSingleExtrusionMachine(machine, "fdmextruder")
-
- def _onContainerAdded(self, container: ContainerInterface) -> None:
- # We don't have all the machines loaded in the beginning, so in order to add the missing extruder stack
- # for single extrusion machines, we subscribe to the containerAdded signal, and whenever a global stack
- # is added, we check to see if an extruder stack needs to be added.
- if not isinstance(container, ContainerStack) or container.getMetaDataEntry("type") != "machine":
- return
-
- machine_extruder_trains = container.getMetaDataEntry("machine_extruder_trains")
- if machine_extruder_trains is not None and machine_extruder_trains != {"0": "fdmextruder"}:
- return
-
- extruder_stacks = self.findContainerStacks(type = "extruder_train", machine = container.getId())
- if not extruder_stacks:
- self.addExtruderStackForSingleExtrusionMachine(container, "fdmextruder")
-
- #
- # new_global_quality_changes is optional. It is only used in project loading for a scenario like this:
- # - override the current machine
- # - create new for custom quality profile
- # new_global_quality_changes is the new global quality changes container in this scenario.
- # create_new_ids indicates if new unique ids must be created
- #
- def addExtruderStackForSingleExtrusionMachine(self, machine, extruder_id, new_global_quality_changes = None, create_new_ids = True):
- new_extruder_id = extruder_id
-
- application = cura.CuraApplication.CuraApplication.getInstance()
-
- extruder_definitions = self.findDefinitionContainers(id = new_extruder_id)
- if not extruder_definitions:
- Logger.log("w", "Could not find definition containers for extruder %s", new_extruder_id)
- return
-
- extruder_definition = extruder_definitions[0]
- unique_name = self.uniqueName(machine.getName() + " " + new_extruder_id) if create_new_ids else machine.getName() + " " + new_extruder_id
-
- extruder_stack = ExtruderStack.ExtruderStack(unique_name)
- extruder_stack.setName(extruder_definition.getName())
- extruder_stack.setDefinition(extruder_definition)
- extruder_stack.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position"))
-
- # create a new definition_changes container for the extruder stack
- definition_changes_id = self.uniqueName(extruder_stack.getId() + "_settings") if create_new_ids else extruder_stack.getId() + "_settings"
- definition_changes_name = definition_changes_id
- definition_changes = InstanceContainer(definition_changes_id, parent = application)
- definition_changes.setName(definition_changes_name)
- definition_changes.setMetaDataEntry("setting_version", application.SettingVersion)
- definition_changes.setMetaDataEntry("type", "definition_changes")
- definition_changes.setMetaDataEntry("definition", extruder_definition.getId())
-
- # move definition_changes settings if exist
- for setting_key in definition_changes.getAllKeys():
- if machine.definition.getProperty(setting_key, "settable_per_extruder"):
- setting_value = machine.definitionChanges.getProperty(setting_key, "value")
- if setting_value is not None:
- # move it to the extruder stack's definition_changes
- setting_definition = machine.getSettingDefinition(setting_key)
- new_instance = SettingInstance(setting_definition, definition_changes)
- new_instance.setProperty("value", setting_value)
- new_instance.resetState() # Ensure that the state is not seen as a user state.
- definition_changes.addInstance(new_instance)
- definition_changes.setDirty(True)
-
- machine.definitionChanges.removeInstance(setting_key, postpone_emit = True)
-
- self.addContainer(definition_changes)
- extruder_stack.setDefinitionChanges(definition_changes)
-
- # create empty user changes container otherwise
- user_container_id = self.uniqueName(extruder_stack.getId() + "_user") if create_new_ids else extruder_stack.getId() + "_user"
- user_container_name = user_container_id
- user_container = InstanceContainer(user_container_id, parent = application)
- user_container.setName(user_container_name)
- user_container.setMetaDataEntry("type", "user")
- user_container.setMetaDataEntry("machine", machine.getId())
- user_container.setMetaDataEntry("setting_version", application.SettingVersion)
- user_container.setDefinition(machine.definition.getId())
- user_container.setMetaDataEntry("position", extruder_stack.getMetaDataEntry("position"))
-
- if machine.userChanges:
- # For the newly created extruder stack, we need to move all "per-extruder" settings to the user changes
- # container to the extruder stack.
- for user_setting_key in machine.userChanges.getAllKeys():
- settable_per_extruder = machine.getProperty(user_setting_key, "settable_per_extruder")
- if settable_per_extruder:
- setting_value = machine.getProperty(user_setting_key, "value")
-
- setting_definition = machine.getSettingDefinition(user_setting_key)
- new_instance = SettingInstance(setting_definition, definition_changes)
- new_instance.setProperty("value", setting_value)
- new_instance.resetState() # Ensure that the state is not seen as a user state.
- user_container.addInstance(new_instance)
- user_container.setDirty(True)
-
- machine.userChanges.removeInstance(user_setting_key, postpone_emit = True)
-
- self.addContainer(user_container)
- extruder_stack.setUserChanges(user_container)
-
- empty_variant = application.empty_variant_container
- empty_material = application.empty_material_container
- empty_quality = application.empty_quality_container
-
- if machine.variant.getId() not in ("empty", "empty_variant"):
- variant = machine.variant
- else:
- variant = empty_variant
- extruder_stack.variant = variant
-
- if machine.material.getId() not in ("empty", "empty_material"):
- material = machine.material
- else:
- material = empty_material
- extruder_stack.material = material
-
- if machine.quality.getId() not in ("empty", "empty_quality"):
- quality = machine.quality
- else:
- quality = empty_quality
- extruder_stack.quality = quality
-
- machine_quality_changes = machine.qualityChanges
- if new_global_quality_changes is not None:
- machine_quality_changes = new_global_quality_changes
-
- if machine_quality_changes.getId() not in ("empty", "empty_quality_changes"):
- extruder_quality_changes_container = self.findInstanceContainers(name = machine_quality_changes.getName(), extruder = extruder_id)
- if extruder_quality_changes_container:
- extruder_quality_changes_container = extruder_quality_changes_container[0]
-
- quality_changes_id = extruder_quality_changes_container.getId()
- extruder_stack.qualityChanges = self.findInstanceContainers(id = quality_changes_id)[0]
- else:
- # Some extruder quality_changes containers can be created at runtime as files in the qualities
- # folder. Those files won't be loaded in the registry immediately. So we also need to search
- # the folder to see if the quality_changes exists.
- extruder_quality_changes_container = self._findQualityChangesContainerInCuraFolder(machine_quality_changes.getName())
- if extruder_quality_changes_container:
- quality_changes_id = extruder_quality_changes_container.getId()
- extruder_quality_changes_container.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position"))
- extruder_stack.qualityChanges = self.findInstanceContainers(id = quality_changes_id)[0]
- else:
- # If we still cannot find a quality changes container for the extruder, create a new one
- container_name = machine_quality_changes.getName()
- container_id = self.uniqueName(extruder_stack.getId() + "_qc_" + container_name)
- extruder_quality_changes_container = InstanceContainer(container_id, parent = application)
- extruder_quality_changes_container.setName(container_name)
- extruder_quality_changes_container.setMetaDataEntry("type", "quality_changes")
- extruder_quality_changes_container.setMetaDataEntry("setting_version", application.SettingVersion)
- extruder_quality_changes_container.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position"))
- extruder_quality_changes_container.setMetaDataEntry("quality_type", machine_quality_changes.getMetaDataEntry("quality_type"))
- extruder_quality_changes_container.setMetaDataEntry("intent_category", "default") # Intent categories weren't a thing back then.
- extruder_quality_changes_container.setDefinition(machine_quality_changes.getDefinition().getId())
-
- self.addContainer(extruder_quality_changes_container)
- extruder_stack.qualityChanges = extruder_quality_changes_container
-
- if not extruder_quality_changes_container:
- Logger.log("w", "Could not find quality_changes named [%s] for extruder [%s]",
- machine_quality_changes.getName(), extruder_stack.getId())
- else:
- # Move all per-extruder settings to the extruder's quality changes
- for qc_setting_key in machine_quality_changes.getAllKeys():
- settable_per_extruder = machine.getProperty(qc_setting_key, "settable_per_extruder")
- if settable_per_extruder:
- setting_value = machine_quality_changes.getProperty(qc_setting_key, "value")
-
- setting_definition = machine.getSettingDefinition(qc_setting_key)
- new_instance = SettingInstance(setting_definition, definition_changes)
- new_instance.setProperty("value", setting_value)
- new_instance.resetState() # Ensure that the state is not seen as a user state.
- extruder_quality_changes_container.addInstance(new_instance)
- extruder_quality_changes_container.setDirty(True)
-
- machine_quality_changes.removeInstance(qc_setting_key, postpone_emit=True)
- else:
- extruder_stack.qualityChanges = self.findInstanceContainers(id = "empty_quality_changes")[0]
-
- self.addContainer(extruder_stack)
-
- # Also need to fix the other qualities that are suitable for this machine. Those quality changes may still have
- # per-extruder settings in the container for the machine instead of the extruder.
- if machine_quality_changes.getId() not in ("empty", "empty_quality_changes"):
- quality_changes_machine_definition_id = machine_quality_changes.getDefinition().getId()
- else:
- whole_machine_definition = machine.definition
- machine_entry = machine.definition.getMetaDataEntry("machine")
- if machine_entry is not None:
- container_registry = ContainerRegistry.getInstance()
- whole_machine_definition = container_registry.findDefinitionContainers(id = machine_entry)[0]
-
- quality_changes_machine_definition_id = "fdmprinter"
- if whole_machine_definition.getMetaDataEntry("has_machine_quality"):
- quality_changes_machine_definition_id = machine.definition.getMetaDataEntry("quality_definition",
- whole_machine_definition.getId())
- qcs = self.findInstanceContainers(type = "quality_changes", definition = quality_changes_machine_definition_id)
- qc_groups = {} # map of qc names -> qc containers
- for qc in qcs:
- qc_name = qc.getName()
- if qc_name not in qc_groups:
- qc_groups[qc_name] = []
- qc_groups[qc_name].append(qc)
- # Try to find from the quality changes cura directory too
- quality_changes_container = self._findQualityChangesContainerInCuraFolder(machine_quality_changes.getName())
- if quality_changes_container:
- qc_groups[qc_name].append(quality_changes_container)
-
- for qc_name, qc_list in qc_groups.items():
- qc_dict = {"global": None, "extruders": []}
- for qc in qc_list:
- extruder_position = qc.getMetaDataEntry("position")
- if extruder_position is not None:
- qc_dict["extruders"].append(qc)
- else:
- qc_dict["global"] = qc
- if qc_dict["global"] is not None and len(qc_dict["extruders"]) == 1:
- # Move per-extruder settings
- for qc_setting_key in qc_dict["global"].getAllKeys():
- settable_per_extruder = machine.getProperty(qc_setting_key, "settable_per_extruder")
- if settable_per_extruder:
- setting_value = qc_dict["global"].getProperty(qc_setting_key, "value")
-
- setting_definition = machine.getSettingDefinition(qc_setting_key)
- new_instance = SettingInstance(setting_definition, definition_changes)
- new_instance.setProperty("value", setting_value)
- new_instance.resetState() # Ensure that the state is not seen as a user state.
- qc_dict["extruders"][0].addInstance(new_instance)
- qc_dict["extruders"][0].setDirty(True)
-
- qc_dict["global"].removeInstance(qc_setting_key, postpone_emit=True)
-
- # Set next stack at the end
- extruder_stack.setNextStack(machine)
-
- return extruder_stack
-
- def _findQualityChangesContainerInCuraFolder(self, name: str) -> Optional[InstanceContainer]:
- quality_changes_dir = Resources.getPath(cura.CuraApplication.CuraApplication.ResourceTypes.QualityChangesInstanceContainer)
-
- instance_container = None
-
- for item in os.listdir(quality_changes_dir):
- file_path = os.path.join(quality_changes_dir, item)
- if not os.path.isfile(file_path):
- continue
-
- parser = configparser.ConfigParser(interpolation = None)
- try:
- parser.read([file_path])
- except Exception:
- # Skip, it is not a valid stack file
- continue
-
- if not parser.has_option("general", "name"):
- continue
-
- if parser["general"]["name"] == name:
- # Load the container
- container_id = os.path.basename(file_path).replace(".inst.cfg", "")
- if self.findInstanceContainers(id = container_id):
- # This container is already in the registry, skip it
- continue
-
- instance_container = InstanceContainer(container_id)
- with open(file_path, "r", encoding = "utf-8") as f:
- serialized = f.read()
- try:
- instance_container.deserialize(serialized, file_path)
- except ContainerFormatError:
- Logger.logException("e", "Unable to deserialize InstanceContainer %s", file_path)
- continue
- self.addContainer(instance_container)
- break
-
- return instance_container
-
- # Fix the extruders that were upgraded to ExtruderStack instances during addContainer.
- # The stacks are now responsible for setting the next stack on deserialize. However,
- # due to problems with loading order, some stacks may not have the proper next stack
- # set after upgrading, because the proper global stack was not yet loaded. This method
- # makes sure those extruders also get the right stack set.
- def _connectUpgradedExtruderStacksToMachines(self) -> None:
- extruder_stacks = self.findContainers(container_type = ExtruderStack.ExtruderStack)
- for extruder_stack in extruder_stacks:
- if extruder_stack.getNextStack():
- # Has the right next stack, so ignore it.
- continue
-
- machines = ContainerRegistry.getInstance().findContainerStacks(id = extruder_stack.getMetaDataEntry("machine", ""))
- if machines:
- extruder_stack.setNextStack(machines[0])
- else:
- Logger.log("w", "Could not find machine {machine} for extruder {extruder}", machine = extruder_stack.getMetaDataEntry("machine"), extruder = extruder_stack.getId())
-
- # Override just for the type.
- @classmethod
- @override(ContainerRegistry)
- def getInstance(cls, *args, **kwargs) -> "CuraContainerRegistry":
- return cast(CuraContainerRegistry, super().getInstance(*args, **kwargs))
+# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import os +import re +import configparser + +from typing import Any, cast, Dict, Optional, List, Union, Tuple +from PyQt5.QtWidgets import QMessageBox + +from UM.Decorators import override +from UM.Settings.ContainerFormatError import ContainerFormatError +from UM.Settings.Interfaces import ContainerInterface +from UM.Settings.ContainerRegistry import ContainerRegistry +from UM.Settings.ContainerStack import ContainerStack +from UM.Settings.InstanceContainer import InstanceContainer +from UM.Settings.SettingInstance import SettingInstance +from UM.Logger import Logger +from UM.Message import Message +from UM.Platform import Platform +from UM.PluginRegistry import PluginRegistry # For getting the possible profile writers to write with. +from UM.Resources import Resources +from UM.Util import parseBool +from cura.ReaderWriters.ProfileWriter import ProfileWriter + +from . import ExtruderStack +from . import GlobalStack + +import cura.CuraApplication +from cura.Settings.cura_empty_instance_containers import empty_quality_container +from cura.Machines.ContainerTree import ContainerTree +from cura.ReaderWriters.ProfileReader import NoProfileException, ProfileReader + +from UM.i18n import i18nCatalog +catalog = i18nCatalog("cura") + + +class CuraContainerRegistry(ContainerRegistry): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # We don't have all the machines loaded in the beginning, so in order to add the missing extruder stack + # for single extrusion machines, we subscribe to the containerAdded signal, and whenever a global stack + # is added, we check to see if an extruder stack needs to be added. + self.containerAdded.connect(self._onContainerAdded) + + @override(ContainerRegistry) + def addContainer(self, container: ContainerInterface) -> bool: + """Overridden from ContainerRegistry + + Adds a container to the registry. + + This will also try to convert a ContainerStack to either Extruder or + Global stack based on metadata information. + """ + + # Note: Intentional check with type() because we want to ignore subclasses + if type(container) == ContainerStack: + container = self._convertContainerStack(cast(ContainerStack, container)) + + if isinstance(container, InstanceContainer) and type(container) != type(self.getEmptyInstanceContainer()): + # Check against setting version of the definition. + required_setting_version = cura.CuraApplication.CuraApplication.SettingVersion + actual_setting_version = int(container.getMetaDataEntry("setting_version", default = 0)) + if required_setting_version != actual_setting_version: + Logger.log("w", "Instance container {container_id} is outdated. Its setting version is {actual_setting_version} but it should be {required_setting_version}.".format(container_id = container.getId(), actual_setting_version = actual_setting_version, required_setting_version = required_setting_version)) + return False # Don't add. + + return super().addContainer(container) + + def createUniqueName(self, container_type: str, current_name: str, new_name: str, fallback_name: str) -> str: + """Create a name that is not empty and unique + + :param container_type: :type{string} Type of the container (machine, quality, ...) + :param current_name: :type{} Current name of the container, which may be an acceptable option + :param new_name: :type{string} Base name, which may not be unique + :param fallback_name: :type{string} Name to use when (stripped) new_name is empty + :return: :type{string} Name that is unique for the specified type and name/id + """ + new_name = new_name.strip() + num_check = re.compile(r"(.*?)\s*#\d+$").match(new_name) + if num_check: + new_name = num_check.group(1) + if new_name == "": + new_name = fallback_name + + unique_name = new_name + i = 1 + # In case we are renaming, the current name of the container is also a valid end-result + while self._containerExists(container_type, unique_name) and unique_name != current_name: + i += 1 + unique_name = "%s #%d" % (new_name, i) + + return unique_name + + def _containerExists(self, container_type: str, container_name: str): + """Check if a container with of a certain type and a certain name or id exists + + Both the id and the name are checked, because they may not be the same and it is better if they are both unique + :param container_type: :type{string} Type of the container (machine, quality, ...) + :param container_name: :type{string} Name to check + """ + container_class = ContainerStack if container_type == "machine" else InstanceContainer + + return self.findContainersMetadata(container_type = container_class, id = container_name, type = container_type, ignore_case = True) or \ + self.findContainersMetadata(container_type = container_class, name = container_name, type = container_type) + + def exportQualityProfile(self, container_list: List[InstanceContainer], file_name: str, file_type: str) -> bool: + """Exports an profile to a file + + :param container_list: :type{list} the containers to export. This is not + necessarily in any order! + :param file_name: :type{str} the full path and filename to export to. + :param file_type: :type{str} the file type with the format "<description> (*.<extension>)" + :return: True if the export succeeded, false otherwise. + """ + + # Parse the fileType to deduce what plugin can save the file format. + # fileType has the format "<description> (*.<extension>)" + split = file_type.rfind(" (*.") # Find where the description ends and the extension starts. + if split < 0: # Not found. Invalid format. + Logger.log("e", "Invalid file format identifier %s", file_type) + return False + description = file_type[:split] + extension = file_type[split + 4:-1] # Leave out the " (*." and ")". + if not file_name.endswith("." + extension): # Auto-fill the extension if the user did not provide any. + file_name += "." + extension + + # On Windows, QML FileDialog properly asks for overwrite confirm, but not on other platforms, so handle those ourself. + if not Platform.isWindows(): + if os.path.exists(file_name): + result = QMessageBox.question(None, catalog.i18nc("@title:window", "File Already Exists"), + catalog.i18nc("@label Don't translate the XML tag <filename>!", "The file <filename>{0}</filename> already exists. Are you sure you want to overwrite it?").format(file_name)) + if result == QMessageBox.No: + return False + + profile_writer = self._findProfileWriter(extension, description) + try: + if profile_writer is None: + raise Exception("Unable to find a profile writer") + success = profile_writer.write(file_name, container_list) + except Exception as e: + Logger.log("e", "Failed to export profile to %s: %s", file_name, str(e)) + m = Message(catalog.i18nc("@info:status Don't translate the XML tags <filename> or <message>!", "Failed to export profile to <filename>{0}</filename>: <message>{1}</message>", file_name, str(e)), + lifetime = 0, + title = catalog.i18nc("@info:title", "Error")) + m.show() + return False + if not success: + Logger.log("w", "Failed to export profile to %s: Writer plugin reported failure.", file_name) + m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Failed to export profile to <filename>{0}</filename>: Writer plugin reported failure.", file_name), + lifetime = 0, + title = catalog.i18nc("@info:title", "Error")) + m.show() + return False + m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Exported profile to <filename>{0}</filename>", file_name), + title = catalog.i18nc("@info:title", "Export succeeded")) + m.show() + return True + + def _findProfileWriter(self, extension: str, description: str) -> Optional[ProfileWriter]: + """Gets the plugin object matching the criteria + + :param extension: + :param description: + :return: The plugin object matching the given extension and description. + """ + plugin_registry = PluginRegistry.getInstance() + for plugin_id, meta_data in self._getIOPlugins("profile_writer"): + for supported_type in meta_data["profile_writer"]: # All file types this plugin can supposedly write. + supported_extension = supported_type.get("extension", None) + if supported_extension == extension: # This plugin supports a file type with the same extension. + supported_description = supported_type.get("description", None) + if supported_description == description: # The description is also identical. Assume it's the same file type. + return cast(ProfileWriter, plugin_registry.getPluginObject(plugin_id)) + return None + + def importProfile(self, file_name: str) -> Dict[str, str]: + """Imports a profile from a file + + :param file_name: The full path and filename of the profile to import. + :return: Dict with a 'status' key containing the string 'ok', 'warning' or 'error', + and a 'message' key containing a message for the user. + """ + + Logger.log("d", "Attempting to import profile %s", file_name) + if not file_name: + return { "status": "error", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "Failed to import profile from <filename>{0}</filename>: {1}", file_name, "Invalid path")} + + global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + if not global_stack: + return {"status": "error", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "Can't import profile from <filename>{0}</filename> before a printer is added.", file_name)} + container_tree = ContainerTree.getInstance() + + machine_extruders = global_stack.extruderList + + plugin_registry = PluginRegistry.getInstance() + extension = file_name.split(".")[-1] + + for plugin_id, meta_data in self._getIOPlugins("profile_reader"): + if meta_data["profile_reader"][0]["extension"] != extension: + continue + profile_reader = cast(ProfileReader, plugin_registry.getPluginObject(plugin_id)) + try: + profile_or_list = profile_reader.read(file_name) # Try to open the file with the profile reader. + except NoProfileException: + return { "status": "ok", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "No custom profile to import in file <filename>{0}</filename>", file_name)} + except Exception as e: + # Note that this will fail quickly. That is, if any profile reader throws an exception, it will stop reading. It will only continue reading if the reader returned None. + Logger.log("e", "Failed to import profile from %s: %s while using profile reader. Got exception %s", file_name, profile_reader.getPluginId(), str(e)) + return { "status": "error", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "Failed to import profile from <filename>{0}</filename>:", file_name) + "\n<message>" + str(e) + "</message>"} + + if profile_or_list: + # Ensure it is always a list of profiles + if not isinstance(profile_or_list, list): + profile_or_list = [profile_or_list] + + # First check if this profile is suitable for this machine + global_profile = None + extruder_profiles = [] + if len(profile_or_list) == 1: + global_profile = profile_or_list[0] + else: + for profile in profile_or_list: + if not profile.getMetaDataEntry("position"): + global_profile = profile + else: + extruder_profiles.append(profile) + extruder_profiles = sorted(extruder_profiles, key = lambda x: int(x.getMetaDataEntry("position", default = "0"))) + profile_or_list = [global_profile] + extruder_profiles + + if not global_profile: + Logger.log("e", "Incorrect profile [%s]. Could not find global profile", file_name) + return { "status": "error", + "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "This profile <filename>{0}</filename> contains incorrect data, could not import it.", file_name)} + profile_definition = global_profile.getMetaDataEntry("definition") + + # Make sure we have a profile_definition in the file: + if profile_definition is None: + break + machine_definitions = self.findContainers(id = profile_definition) + if not machine_definitions: + Logger.log("e", "Incorrect profile [%s]. Unknown machine type [%s]", file_name, profile_definition) + return {"status": "error", + "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "This profile <filename>{0}</filename> contains incorrect data, could not import it.", file_name) + } + machine_definition = machine_definitions[0] + + # Get the expected machine definition. + # i.e.: We expect gcode for a UM2 Extended to be defined as normal UM2 gcode... + has_machine_quality = parseBool(machine_definition.getMetaDataEntry("has_machine_quality", "false")) + profile_definition = machine_definition.getMetaDataEntry("quality_definition", machine_definition.getId()) if has_machine_quality else "fdmprinter" + expected_machine_definition = container_tree.machines[global_stack.definition.getId()].quality_definition + + # And check if the profile_definition matches either one (showing error if not): + if profile_definition != expected_machine_definition: + Logger.log("d", "Profile {file_name} is for machine {profile_definition}, but the current active machine is {expected_machine_definition}. Changing profile's definition.".format(file_name = file_name, profile_definition = profile_definition, expected_machine_definition = expected_machine_definition)) + global_profile.setMetaDataEntry("definition", expected_machine_definition) + for extruder_profile in extruder_profiles: + extruder_profile.setMetaDataEntry("definition", expected_machine_definition) + + quality_name = global_profile.getName() + quality_type = global_profile.getMetaDataEntry("quality_type") + + name_seed = os.path.splitext(os.path.basename(file_name))[0] + new_name = self.uniqueName(name_seed) + + # Ensure it is always a list of profiles + if type(profile_or_list) is not list: + profile_or_list = [profile_or_list] + + # Make sure that there are also extruder stacks' quality_changes, not just one for the global stack + if len(profile_or_list) == 1: + global_profile = profile_or_list[0] + extruder_profiles = [] + for idx, extruder in enumerate(global_stack.extruderList): + profile_id = ContainerRegistry.getInstance().uniqueName(global_stack.getId() + "_extruder_" + str(idx + 1)) + profile = InstanceContainer(profile_id) + profile.setName(quality_name) + profile.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.SettingVersion) + profile.setMetaDataEntry("type", "quality_changes") + profile.setMetaDataEntry("definition", expected_machine_definition) + profile.setMetaDataEntry("quality_type", quality_type) + profile.setDirty(True) + if idx == 0: + # Move all per-extruder settings to the first extruder's quality_changes + for qc_setting_key in global_profile.getAllKeys(): + settable_per_extruder = global_stack.getProperty(qc_setting_key, "settable_per_extruder") + if settable_per_extruder: + setting_value = global_profile.getProperty(qc_setting_key, "value") + + setting_definition = global_stack.getSettingDefinition(qc_setting_key) + if setting_definition is not None: + new_instance = SettingInstance(setting_definition, profile) + new_instance.setProperty("value", setting_value) + new_instance.resetState() # Ensure that the state is not seen as a user state. + profile.addInstance(new_instance) + profile.setDirty(True) + + global_profile.removeInstance(qc_setting_key, postpone_emit = True) + extruder_profiles.append(profile) + + for profile in extruder_profiles: + profile_or_list.append(profile) + + # Import all profiles + profile_ids_added = [] # type: List[str] + additional_message = None + for profile_index, profile in enumerate(profile_or_list): + if profile_index == 0: + # This is assumed to be the global profile + profile_id = (cast(ContainerInterface, global_stack.getBottom()).getId() + "_" + name_seed).lower().replace(" ", "_") + + elif profile_index < len(machine_extruders) + 1: + # This is assumed to be an extruder profile + extruder_id = machine_extruders[profile_index - 1].definition.getId() + extruder_position = str(profile_index - 1) + if not profile.getMetaDataEntry("position"): + profile.setMetaDataEntry("position", extruder_position) + else: + profile.setMetaDataEntry("position", extruder_position) + profile_id = (extruder_id + "_" + name_seed).lower().replace(" ", "_") + + else: # More extruders in the imported file than in the machine. + continue # Delete the additional profiles. + + configuration_successful, message = self._configureProfile(profile, profile_id, new_name, expected_machine_definition) + if configuration_successful: + additional_message = message + else: + # Remove any profiles that were added. + for profile_id in profile_ids_added + [profile.getId()]: + self.removeContainer(profile_id) + if not message: + message = "" + return {"status": "error", "message": catalog.i18nc( + "@info:status Don't translate the XML tag <filename>!", + "Failed to import profile from <filename>{0}</filename>:", + file_name) + " " + message} + profile_ids_added.append(profile.getId()) + result_status = "ok" + success_message = catalog.i18nc("@info:status", "Successfully imported profile {0}.", profile_or_list[0].getName()) + if additional_message: + result_status = "warning" + success_message += additional_message + return {"status": result_status, "message": success_message} + + # This message is throw when the profile reader doesn't find any profile in the file + return {"status": "error", "message": catalog.i18nc("@info:status", "File {0} does not contain any valid profile.", file_name)} + + # If it hasn't returned by now, none of the plugins loaded the profile successfully. + return {"status": "error", "message": catalog.i18nc("@info:status", "Profile {0} has an unknown file type or is corrupted.", file_name)} + + @override(ContainerRegistry) + def load(self) -> None: + super().load() + self._registerSingleExtrusionMachinesExtruderStacks() + self._connectUpgradedExtruderStacksToMachines() + + @override(ContainerRegistry) + def loadAllMetadata(self) -> None: + super().loadAllMetadata() + self._cleanUpInvalidQualityChanges() + + def _cleanUpInvalidQualityChanges(self) -> None: + # We've seen cases where it was possible for quality_changes to be incorrectly added. This is to ensure that + # any such leftovers are purged from the registry. + quality_changes = ContainerRegistry.getInstance().findContainersMetadata(type="quality_changes") + + profile_count_by_name = {} # type: Dict[str, int] + + for quality_change in quality_changes: + name = str(quality_change.get("name", "")) + if name == "empty": + continue + if name not in profile_count_by_name: + profile_count_by_name[name] = 0 + profile_count_by_name[name] += 1 + + for profile_name, profile_count in profile_count_by_name.items(): + if profile_count > 1: + continue + # Only one profile found, this should not ever be the case, so that profile needs to be removed! + Logger.log("d", "Found an invalid quality_changes profile with the name %s. Going to remove that now", profile_name) + invalid_quality_changes = ContainerRegistry.getInstance().findContainersMetadata(name=profile_name) + self.removeContainer(invalid_quality_changes[0]["id"]) + + @override(ContainerRegistry) + def _isMetadataValid(self, metadata: Optional[Dict[str, Any]]) -> bool: + """Check if the metadata for a container is okay before adding it. + + This overrides the one from UM.Settings.ContainerRegistry because we + also require that the setting_version is correct. + """ + + if metadata is None: + return False + if "setting_version" not in metadata: + return False + try: + if int(metadata["setting_version"]) != cura.CuraApplication.CuraApplication.SettingVersion: + return False + except ValueError: #Not parsable as int. + return False + return True + + def _configureProfile(self, profile: InstanceContainer, id_seed: str, new_name: str, machine_definition_id: str) -> Tuple[bool, Optional[str]]: + """Update an imported profile to match the current machine configuration. + + :param profile: The profile to configure. + :param id_seed: The base ID for the profile. May be changed so it does not conflict with existing containers. + :param new_name: The new name for the profile. + + :returns: tuple (configuration_successful, message) + WHERE + bool configuration_successful: Whether the process of configuring the profile was successful + optional str message: A message indicating the outcome of configuring the profile. If the configuration + is successful, this message can be None or contain a warning + """ + + profile.setDirty(True) # Ensure the profiles are correctly saved + + new_id = self.createUniqueName("quality_changes", "", id_seed, catalog.i18nc("@label", "Custom profile")) + profile.setMetaDataEntry("id", new_id) + profile.setName(new_name) + + # Set the unique Id to the profile, so it's generating a new one even if the user imports the same profile + # It also solves an issue with importing profiles from G-Codes + profile.setMetaDataEntry("id", new_id) + profile.setMetaDataEntry("definition", machine_definition_id) + + if "type" in profile.getMetaData(): + profile.setMetaDataEntry("type", "quality_changes") + else: + profile.setMetaDataEntry("type", "quality_changes") + + quality_type = profile.getMetaDataEntry("quality_type") + if not quality_type: + return False, catalog.i18nc("@info:status", "Profile is missing a quality type.") + + global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + if not global_stack: + return False, catalog.i18nc("@info:status", "There is no active printer yet.") + + definition_id = ContainerTree.getInstance().machines[global_stack.definition.getId()].quality_definition + profile.setDefinition(definition_id) + + if not self.addContainer(profile): + return False, catalog.i18nc("@info:status", "Unable to add the profile.") + + # "not_supported" profiles can be imported. + if quality_type == empty_quality_container.getMetaDataEntry("quality_type"): + return True, None + + # Check to make sure the imported profile actually makes sense in context of the current configuration. + # This prevents issues where importing a "draft" profile for a machine without "draft" qualities would report as + # successfully imported but then fail to show up. + available_quality_groups_dict = {name: quality_group for name, quality_group in ContainerTree.getInstance().getCurrentQualityGroups().items() if quality_group.is_available} + all_quality_groups_dict = ContainerTree.getInstance().getCurrentQualityGroups() + + # If the quality type doesn't exist at all in the quality_groups of this machine, reject the profile + if quality_type not in all_quality_groups_dict: + return False, catalog.i18nc("@info:status", "Quality type '{0}' is not compatible with the current active machine definition '{1}'.", quality_type, definition_id) + + # If the quality_type exists in the quality_groups of this printer but it is not available with the current + # machine configuration (e.g. not available for the selected nozzles), accept it with a warning + if quality_type not in available_quality_groups_dict: + return True, "\n\n" + catalog.i18nc("@info:status", "Warning: The profile is not visible because its quality type '{0}' is not available for the current configuration. " + "Switch to a material/nozzle combination that can use this quality type.", quality_type) + + return True, None + + @override(ContainerRegistry) + def saveDirtyContainers(self) -> None: + # Lock file for "more" atomically loading and saving to/from config dir. + with self.lockFile(): + # Save base files first + for instance in self.findDirtyContainers(container_type=InstanceContainer): + if instance.getMetaDataEntry("removed"): + continue + if instance.getId() == instance.getMetaData().get("base_file"): + self.saveContainer(instance) + + for instance in self.findDirtyContainers(container_type=InstanceContainer): + if instance.getMetaDataEntry("removed"): + continue + self.saveContainer(instance) + + for stack in self.findContainerStacks(): + self.saveContainer(stack) + + def _getIOPlugins(self, io_type): + """Gets a list of profile writer plugins + + :return: List of tuples of (plugin_id, meta_data). + """ + plugin_registry = PluginRegistry.getInstance() + active_plugin_ids = plugin_registry.getActivePlugins() + + result = [] + for plugin_id in active_plugin_ids: + meta_data = plugin_registry.getMetaData(plugin_id) + if io_type in meta_data: + result.append( (plugin_id, meta_data) ) + return result + + def _convertContainerStack(self, container: ContainerStack) -> Union[ExtruderStack.ExtruderStack, GlobalStack.GlobalStack]: + """Convert an "old-style" pure ContainerStack to either an Extruder or Global stack.""" + + assert type(container) == ContainerStack + + container_type = container.getMetaDataEntry("type") + if container_type not in ("extruder_train", "machine"): + # It is not an extruder or machine, so do nothing with the stack + return container + + Logger.log("d", "Converting ContainerStack {stack} to {type}", stack = container.getId(), type = container_type) + + if container_type == "extruder_train": + new_stack = ExtruderStack.ExtruderStack(container.getId()) + else: + new_stack = GlobalStack.GlobalStack(container.getId()) + + container_contents = container.serialize() + new_stack.deserialize(container_contents) + + # Delete the old configuration file so we do not get double stacks + if os.path.isfile(container.getPath()): + os.remove(container.getPath()) + + return new_stack + + def _registerSingleExtrusionMachinesExtruderStacks(self) -> None: + machines = self.findContainerStacks(type = "machine", machine_extruder_trains = {"0": "fdmextruder"}) + for machine in machines: + extruder_stacks = self.findContainerStacks(type = "extruder_train", machine = machine.getId()) + if not extruder_stacks: + self.addExtruderStackForSingleExtrusionMachine(machine, "fdmextruder") + + def _onContainerAdded(self, container: ContainerInterface) -> None: + # We don't have all the machines loaded in the beginning, so in order to add the missing extruder stack + # for single extrusion machines, we subscribe to the containerAdded signal, and whenever a global stack + # is added, we check to see if an extruder stack needs to be added. + if not isinstance(container, ContainerStack) or container.getMetaDataEntry("type") != "machine": + return + + machine_extruder_trains = container.getMetaDataEntry("machine_extruder_trains") + if machine_extruder_trains is not None and machine_extruder_trains != {"0": "fdmextruder"}: + return + + extruder_stacks = self.findContainerStacks(type = "extruder_train", machine = container.getId()) + if not extruder_stacks: + self.addExtruderStackForSingleExtrusionMachine(container, "fdmextruder") + + # + # new_global_quality_changes is optional. It is only used in project loading for a scenario like this: + # - override the current machine + # - create new for custom quality profile + # new_global_quality_changes is the new global quality changes container in this scenario. + # create_new_ids indicates if new unique ids must be created + # + def addExtruderStackForSingleExtrusionMachine(self, machine, extruder_id, new_global_quality_changes = None, create_new_ids = True): + new_extruder_id = extruder_id + + application = cura.CuraApplication.CuraApplication.getInstance() + + extruder_definitions = self.findDefinitionContainers(id = new_extruder_id) + if not extruder_definitions: + Logger.log("w", "Could not find definition containers for extruder %s", new_extruder_id) + return + + extruder_definition = extruder_definitions[0] + unique_name = self.uniqueName(machine.getName() + " " + new_extruder_id) if create_new_ids else machine.getName() + " " + new_extruder_id + + extruder_stack = ExtruderStack.ExtruderStack(unique_name) + extruder_stack.setName(extruder_definition.getName()) + extruder_stack.setDefinition(extruder_definition) + extruder_stack.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position")) + + # create a new definition_changes container for the extruder stack + definition_changes_id = self.uniqueName(extruder_stack.getId() + "_settings") if create_new_ids else extruder_stack.getId() + "_settings" + definition_changes_name = definition_changes_id + definition_changes = InstanceContainer(definition_changes_id, parent = application) + definition_changes.setName(definition_changes_name) + definition_changes.setMetaDataEntry("setting_version", application.SettingVersion) + definition_changes.setMetaDataEntry("type", "definition_changes") + definition_changes.setMetaDataEntry("definition", extruder_definition.getId()) + + # move definition_changes settings if exist + for setting_key in definition_changes.getAllKeys(): + if machine.definition.getProperty(setting_key, "settable_per_extruder"): + setting_value = machine.definitionChanges.getProperty(setting_key, "value") + if setting_value is not None: + # move it to the extruder stack's definition_changes + setting_definition = machine.getSettingDefinition(setting_key) + new_instance = SettingInstance(setting_definition, definition_changes) + new_instance.setProperty("value", setting_value) + new_instance.resetState() # Ensure that the state is not seen as a user state. + definition_changes.addInstance(new_instance) + definition_changes.setDirty(True) + + machine.definitionChanges.removeInstance(setting_key, postpone_emit = True) + + self.addContainer(definition_changes) + extruder_stack.setDefinitionChanges(definition_changes) + + # create empty user changes container otherwise + user_container_id = self.uniqueName(extruder_stack.getId() + "_user") if create_new_ids else extruder_stack.getId() + "_user" + user_container_name = user_container_id + user_container = InstanceContainer(user_container_id, parent = application) + user_container.setName(user_container_name) + user_container.setMetaDataEntry("type", "user") + user_container.setMetaDataEntry("machine", machine.getId()) + user_container.setMetaDataEntry("setting_version", application.SettingVersion) + user_container.setDefinition(machine.definition.getId()) + user_container.setMetaDataEntry("position", extruder_stack.getMetaDataEntry("position")) + + if machine.userChanges: + # For the newly created extruder stack, we need to move all "per-extruder" settings to the user changes + # container to the extruder stack. + for user_setting_key in machine.userChanges.getAllKeys(): + settable_per_extruder = machine.getProperty(user_setting_key, "settable_per_extruder") + if settable_per_extruder: + setting_value = machine.getProperty(user_setting_key, "value") + + setting_definition = machine.getSettingDefinition(user_setting_key) + new_instance = SettingInstance(setting_definition, definition_changes) + new_instance.setProperty("value", setting_value) + new_instance.resetState() # Ensure that the state is not seen as a user state. + user_container.addInstance(new_instance) + user_container.setDirty(True) + + machine.userChanges.removeInstance(user_setting_key, postpone_emit = True) + + self.addContainer(user_container) + extruder_stack.setUserChanges(user_container) + + empty_variant = application.empty_variant_container + empty_material = application.empty_material_container + empty_quality = application.empty_quality_container + + if machine.variant.getId() not in ("empty", "empty_variant"): + variant = machine.variant + else: + variant = empty_variant + extruder_stack.variant = variant + + if machine.material.getId() not in ("empty", "empty_material"): + material = machine.material + else: + material = empty_material + extruder_stack.material = material + + if machine.quality.getId() not in ("empty", "empty_quality"): + quality = machine.quality + else: + quality = empty_quality + extruder_stack.quality = quality + + machine_quality_changes = machine.qualityChanges + if new_global_quality_changes is not None: + machine_quality_changes = new_global_quality_changes + + if machine_quality_changes.getId() not in ("empty", "empty_quality_changes"): + extruder_quality_changes_container = self.findInstanceContainers(name = machine_quality_changes.getName(), extruder = extruder_id) + if extruder_quality_changes_container: + extruder_quality_changes_container = extruder_quality_changes_container[0] + + quality_changes_id = extruder_quality_changes_container.getId() + extruder_stack.qualityChanges = self.findInstanceContainers(id = quality_changes_id)[0] + else: + # Some extruder quality_changes containers can be created at runtime as files in the qualities + # folder. Those files won't be loaded in the registry immediately. So we also need to search + # the folder to see if the quality_changes exists. + extruder_quality_changes_container = self._findQualityChangesContainerInCuraFolder(machine_quality_changes.getName()) + if extruder_quality_changes_container: + quality_changes_id = extruder_quality_changes_container.getId() + extruder_quality_changes_container.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position")) + extruder_stack.qualityChanges = self.findInstanceContainers(id = quality_changes_id)[0] + else: + # If we still cannot find a quality changes container for the extruder, create a new one + container_name = machine_quality_changes.getName() + container_id = self.uniqueName(extruder_stack.getId() + "_qc_" + container_name) + extruder_quality_changes_container = InstanceContainer(container_id, parent = application) + extruder_quality_changes_container.setName(container_name) + extruder_quality_changes_container.setMetaDataEntry("type", "quality_changes") + extruder_quality_changes_container.setMetaDataEntry("setting_version", application.SettingVersion) + extruder_quality_changes_container.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position")) + extruder_quality_changes_container.setMetaDataEntry("quality_type", machine_quality_changes.getMetaDataEntry("quality_type")) + extruder_quality_changes_container.setMetaDataEntry("intent_category", "default") # Intent categories weren't a thing back then. + extruder_quality_changes_container.setDefinition(machine_quality_changes.getDefinition().getId()) + + self.addContainer(extruder_quality_changes_container) + extruder_stack.qualityChanges = extruder_quality_changes_container + + if not extruder_quality_changes_container: + Logger.log("w", "Could not find quality_changes named [%s] for extruder [%s]", + machine_quality_changes.getName(), extruder_stack.getId()) + else: + # Move all per-extruder settings to the extruder's quality changes + for qc_setting_key in machine_quality_changes.getAllKeys(): + settable_per_extruder = machine.getProperty(qc_setting_key, "settable_per_extruder") + if settable_per_extruder: + setting_value = machine_quality_changes.getProperty(qc_setting_key, "value") + + setting_definition = machine.getSettingDefinition(qc_setting_key) + new_instance = SettingInstance(setting_definition, definition_changes) + new_instance.setProperty("value", setting_value) + new_instance.resetState() # Ensure that the state is not seen as a user state. + extruder_quality_changes_container.addInstance(new_instance) + extruder_quality_changes_container.setDirty(True) + + machine_quality_changes.removeInstance(qc_setting_key, postpone_emit=True) + else: + extruder_stack.qualityChanges = self.findInstanceContainers(id = "empty_quality_changes")[0] + + self.addContainer(extruder_stack) + + # Also need to fix the other qualities that are suitable for this machine. Those quality changes may still have + # per-extruder settings in the container for the machine instead of the extruder. + if machine_quality_changes.getId() not in ("empty", "empty_quality_changes"): + quality_changes_machine_definition_id = machine_quality_changes.getDefinition().getId() + else: + whole_machine_definition = machine.definition + machine_entry = machine.definition.getMetaDataEntry("machine") + if machine_entry is not None: + container_registry = ContainerRegistry.getInstance() + whole_machine_definition = container_registry.findDefinitionContainers(id = machine_entry)[0] + + quality_changes_machine_definition_id = "fdmprinter" + if whole_machine_definition.getMetaDataEntry("has_machine_quality"): + quality_changes_machine_definition_id = machine.definition.getMetaDataEntry("quality_definition", + whole_machine_definition.getId()) + qcs = self.findInstanceContainers(type = "quality_changes", definition = quality_changes_machine_definition_id) + qc_groups = {} # map of qc names -> qc containers + for qc in qcs: + qc_name = qc.getName() + if qc_name not in qc_groups: + qc_groups[qc_name] = [] + qc_groups[qc_name].append(qc) + # Try to find from the quality changes cura directory too + quality_changes_container = self._findQualityChangesContainerInCuraFolder(machine_quality_changes.getName()) + if quality_changes_container: + qc_groups[qc_name].append(quality_changes_container) + + for qc_name, qc_list in qc_groups.items(): + qc_dict = {"global": None, "extruders": []} + for qc in qc_list: + extruder_position = qc.getMetaDataEntry("position") + if extruder_position is not None: + qc_dict["extruders"].append(qc) + else: + qc_dict["global"] = qc + if qc_dict["global"] is not None and len(qc_dict["extruders"]) == 1: + # Move per-extruder settings + for qc_setting_key in qc_dict["global"].getAllKeys(): + settable_per_extruder = machine.getProperty(qc_setting_key, "settable_per_extruder") + if settable_per_extruder: + setting_value = qc_dict["global"].getProperty(qc_setting_key, "value") + + setting_definition = machine.getSettingDefinition(qc_setting_key) + new_instance = SettingInstance(setting_definition, definition_changes) + new_instance.setProperty("value", setting_value) + new_instance.resetState() # Ensure that the state is not seen as a user state. + qc_dict["extruders"][0].addInstance(new_instance) + qc_dict["extruders"][0].setDirty(True) + + qc_dict["global"].removeInstance(qc_setting_key, postpone_emit=True) + + # Set next stack at the end + extruder_stack.setNextStack(machine) + + return extruder_stack + + def _findQualityChangesContainerInCuraFolder(self, name: str) -> Optional[InstanceContainer]: + quality_changes_dir = Resources.getPath(cura.CuraApplication.CuraApplication.ResourceTypes.QualityChangesInstanceContainer) + + instance_container = None + + for item in os.listdir(quality_changes_dir): + file_path = os.path.join(quality_changes_dir, item) + if not os.path.isfile(file_path): + continue + + parser = configparser.ConfigParser(interpolation = None) + try: + parser.read([file_path]) + except Exception: + # Skip, it is not a valid stack file + continue + + if not parser.has_option("general", "name"): + continue + + if parser["general"]["name"] == name: + # Load the container + container_id = os.path.basename(file_path).replace(".inst.cfg", "") + if self.findInstanceContainers(id = container_id): + # This container is already in the registry, skip it + continue + + instance_container = InstanceContainer(container_id) + with open(file_path, "r", encoding = "utf-8") as f: + serialized = f.read() + try: + instance_container.deserialize(serialized, file_path) + except ContainerFormatError: + Logger.logException("e", "Unable to deserialize InstanceContainer %s", file_path) + continue + self.addContainer(instance_container) + break + + return instance_container + + # Fix the extruders that were upgraded to ExtruderStack instances during addContainer. + # The stacks are now responsible for setting the next stack on deserialize. However, + # due to problems with loading order, some stacks may not have the proper next stack + # set after upgrading, because the proper global stack was not yet loaded. This method + # makes sure those extruders also get the right stack set. + def _connectUpgradedExtruderStacksToMachines(self) -> None: + extruder_stacks = self.findContainers(container_type = ExtruderStack.ExtruderStack) + for extruder_stack in extruder_stacks: + if extruder_stack.getNextStack(): + # Has the right next stack, so ignore it. + continue + + machines = ContainerRegistry.getInstance().findContainerStacks(id = extruder_stack.getMetaDataEntry("machine", "")) + if machines: + extruder_stack.setNextStack(machines[0]) + else: + Logger.log("w", "Could not find machine {machine} for extruder {extruder}", machine = extruder_stack.getMetaDataEntry("machine"), extruder = extruder_stack.getId()) + + # Override just for the type. + @classmethod + @override(ContainerRegistry) + def getInstance(cls, *args, **kwargs) -> "CuraContainerRegistry": + return cast(CuraContainerRegistry, super().getInstance(*args, **kwargs)) diff --git a/cura/Settings/CuraContainerStack.py b/cura/Settings/CuraContainerStack.py index 1455e140a8..f594ad3d0c 100755 --- a/cura/Settings/CuraContainerStack.py +++ b/cura/Settings/CuraContainerStack.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Any, cast, List, Optional +from typing import Any, cast, List, Optional, Dict from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject from UM.Application import Application @@ -18,25 +18,27 @@ from cura.Settings import cura_empty_instance_containers from . import Exceptions -## Base class for Cura related stacks that want to enforce certain containers are available. -# -# This class makes sure that the stack has the following containers set: user changes, quality -# changes, quality, material, variant, definition changes and finally definition. Initially, -# these will be equal to the empty instance container. -# -# The container types are determined based on the following criteria: -# - user: An InstanceContainer with the metadata entry "type" set to "user". -# - quality changes: An InstanceContainer with the metadata entry "type" set to "quality_changes". -# - quality: An InstanceContainer with the metadata entry "type" set to "quality". -# - material: An InstanceContainer with the metadata entry "type" set to "material". -# - variant: An InstanceContainer with the metadata entry "type" set to "variant". -# - definition changes: An InstanceContainer with the metadata entry "type" set to "definition_changes". -# - definition: A DefinitionContainer. -# -# Internally, this class ensures the mentioned containers are always there and kept in a specific order. -# This also means that operations on the stack that modifies the container ordering is prohibited and -# will raise an exception. class CuraContainerStack(ContainerStack): + """Base class for Cura related stacks that want to enforce certain containers are available. + + This class makes sure that the stack has the following containers set: user changes, quality + changes, quality, material, variant, definition changes and finally definition. Initially, + these will be equal to the empty instance container. + + The container types are determined based on the following criteria: + - user: An InstanceContainer with the metadata entry "type" set to "user". + - quality changes: An InstanceContainer with the metadata entry "type" set to "quality_changes". + - quality: An InstanceContainer with the metadata entry "type" set to "quality". + - material: An InstanceContainer with the metadata entry "type" set to "material". + - variant: An InstanceContainer with the metadata entry "type" set to "variant". + - definition changes: An InstanceContainer with the metadata entry "type" set to "definition_changes". + - definition: A DefinitionContainer. + + Internally, this class ensures the mentioned containers are always there and kept in a specific order. + This also means that operations on the stack that modifies the container ordering is prohibited and + will raise an exception. + """ + def __init__(self, container_id: str) -> None: super().__init__(container_id) @@ -58,104 +60,138 @@ class CuraContainerStack(ContainerStack): import cura.CuraApplication #Here to prevent circular imports. self.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.SettingVersion) + self._settable_per_extruder_cache = {} # type: Dict[str, Any] + + self.setDirty(False) + # This is emitted whenever the containersChanged signal from the ContainerStack base class is emitted. pyqtContainersChanged = pyqtSignal() - ## Set the user changes container. - # - # \param new_user_changes The new user changes container. It is expected to have a "type" metadata entry with the value "user". def setUserChanges(self, new_user_changes: InstanceContainer) -> None: + """Set the user changes container. + + :param new_user_changes: The new user changes container. It is expected to have a "type" metadata entry with the value "user". + """ + self.replaceContainer(_ContainerIndexes.UserChanges, new_user_changes) - ## Get the user changes container. - # - # \return The user changes container. Should always be a valid container, but can be equal to the empty InstanceContainer. @pyqtProperty(InstanceContainer, fset = setUserChanges, notify = pyqtContainersChanged) def userChanges(self) -> InstanceContainer: + """Get the user changes container. + + :return: The user changes container. Should always be a valid container, but can be equal to the empty InstanceContainer. + """ + return cast(InstanceContainer, self._containers[_ContainerIndexes.UserChanges]) - ## Set the quality changes container. - # - # \param new_quality_changes The new quality changes container. It is expected to have a "type" metadata entry with the value "quality_changes". def setQualityChanges(self, new_quality_changes: InstanceContainer, postpone_emit = False) -> None: + """Set the quality changes container. + + :param new_quality_changes: The new quality changes container. It is expected to have a "type" metadata entry with the value "quality_changes". + """ + self.replaceContainer(_ContainerIndexes.QualityChanges, new_quality_changes, postpone_emit = postpone_emit) - ## Get the quality changes container. - # - # \return The quality changes container. Should always be a valid container, but can be equal to the empty InstanceContainer. @pyqtProperty(InstanceContainer, fset = setQualityChanges, notify = pyqtContainersChanged) def qualityChanges(self) -> InstanceContainer: + """Get the quality changes container. + + :return: The quality changes container. Should always be a valid container, but can be equal to the empty InstanceContainer. + """ + return cast(InstanceContainer, self._containers[_ContainerIndexes.QualityChanges]) - ## Set the intent container. - # - # \param new_intent The new intent container. It is expected to have a "type" metadata entry with the value "intent". def setIntent(self, new_intent: InstanceContainer, postpone_emit: bool = False) -> None: + """Set the intent container. + + :param new_intent: The new intent container. It is expected to have a "type" metadata entry with the value "intent". + """ + self.replaceContainer(_ContainerIndexes.Intent, new_intent, postpone_emit = postpone_emit) - ## Get the quality container. - # - # \return The intent container. Should always be a valid container, but can be equal to the empty InstanceContainer. @pyqtProperty(InstanceContainer, fset = setIntent, notify = pyqtContainersChanged) def intent(self) -> InstanceContainer: + """Get the quality container. + + :return: The intent container. Should always be a valid container, but can be equal to the empty InstanceContainer. + """ + return cast(InstanceContainer, self._containers[_ContainerIndexes.Intent]) - ## Set the quality container. - # - # \param new_quality The new quality container. It is expected to have a "type" metadata entry with the value "quality". def setQuality(self, new_quality: InstanceContainer, postpone_emit: bool = False) -> None: + """Set the quality container. + + :param new_quality: The new quality container. It is expected to have a "type" metadata entry with the value "quality". + """ + self.replaceContainer(_ContainerIndexes.Quality, new_quality, postpone_emit = postpone_emit) - ## Get the quality container. - # - # \return The quality container. Should always be a valid container, but can be equal to the empty InstanceContainer. @pyqtProperty(InstanceContainer, fset = setQuality, notify = pyqtContainersChanged) def quality(self) -> InstanceContainer: + """Get the quality container. + + :return: The quality container. Should always be a valid container, but can be equal to the empty InstanceContainer. + """ + return cast(InstanceContainer, self._containers[_ContainerIndexes.Quality]) - ## Set the material container. - # - # \param new_material The new material container. It is expected to have a "type" metadata entry with the value "material". def setMaterial(self, new_material: InstanceContainer, postpone_emit: bool = False) -> None: + """Set the material container. + + :param new_material: The new material container. It is expected to have a "type" metadata entry with the value "material". + """ + self.replaceContainer(_ContainerIndexes.Material, new_material, postpone_emit = postpone_emit) - ## Get the material container. - # - # \return The material container. Should always be a valid container, but can be equal to the empty InstanceContainer. @pyqtProperty(InstanceContainer, fset = setMaterial, notify = pyqtContainersChanged) def material(self) -> InstanceContainer: + """Get the material container. + + :return: The material container. Should always be a valid container, but can be equal to the empty InstanceContainer. + """ + return cast(InstanceContainer, self._containers[_ContainerIndexes.Material]) - ## Set the variant container. - # - # \param new_variant The new variant container. It is expected to have a "type" metadata entry with the value "variant". def setVariant(self, new_variant: InstanceContainer) -> None: + """Set the variant container. + + :param new_variant: The new variant container. It is expected to have a "type" metadata entry with the value "variant". + """ + self.replaceContainer(_ContainerIndexes.Variant, new_variant) - ## Get the variant container. - # - # \return The variant container. Should always be a valid container, but can be equal to the empty InstanceContainer. @pyqtProperty(InstanceContainer, fset = setVariant, notify = pyqtContainersChanged) def variant(self) -> InstanceContainer: + """Get the variant container. + + :return: The variant container. Should always be a valid container, but can be equal to the empty InstanceContainer. + """ + return cast(InstanceContainer, self._containers[_ContainerIndexes.Variant]) - ## Set the definition changes container. - # - # \param new_definition_changes The new definition changes container. It is expected to have a "type" metadata entry with the value "definition_changes". def setDefinitionChanges(self, new_definition_changes: InstanceContainer) -> None: + """Set the definition changes container. + + :param new_definition_changes: The new definition changes container. It is expected to have a "type" metadata entry with the value "definition_changes". + """ + self.replaceContainer(_ContainerIndexes.DefinitionChanges, new_definition_changes) - ## Get the definition changes container. - # - # \return The definition changes container. Should always be a valid container, but can be equal to the empty InstanceContainer. @pyqtProperty(InstanceContainer, fset = setDefinitionChanges, notify = pyqtContainersChanged) def definitionChanges(self) -> InstanceContainer: + """Get the definition changes container. + + :return: The definition changes container. Should always be a valid container, but can be equal to the empty InstanceContainer. + """ + return cast(InstanceContainer, self._containers[_ContainerIndexes.DefinitionChanges]) - ## Set the definition container. - # - # \param new_definition The new definition container. It is expected to have a "type" metadata entry with the value "definition". def setDefinition(self, new_definition: DefinitionContainerInterface) -> None: + """Set the definition container. + + :param new_definition: The new definition container. It is expected to have a "type" metadata entry with the value "definition". + """ + self.replaceContainer(_ContainerIndexes.Definition, new_definition) def getDefinition(self) -> "DefinitionContainer": @@ -171,14 +207,16 @@ class CuraContainerStack(ContainerStack): def getTop(self) -> "InstanceContainer": return self.userChanges - ## Check whether the specified setting has a 'user' value. - # - # A user value here is defined as the setting having a value in either - # the UserChanges or QualityChanges container. - # - # \return True if the setting has a user value, False if not. @pyqtSlot(str, result = bool) def hasUserValue(self, key: str) -> bool: + """Check whether the specified setting has a 'user' value. + + A user value here is defined as the setting having a value in either + the UserChanges or QualityChanges container. + + :return: True if the setting has a user value, False if not. + """ + if self._containers[_ContainerIndexes.UserChanges].hasProperty(key, "value"): return True @@ -187,51 +225,61 @@ class CuraContainerStack(ContainerStack): return False - ## Set a property of a setting. - # - # This will set a property of a specified setting. Since the container stack does not contain - # any settings itself, it is required to specify a container to set the property on. The target - # container is matched by container type. - # - # \param key The key of the setting to set. - # \param property_name The name of the property to set. - # \param new_value The new value to set the property to. def setProperty(self, key: str, property_name: str, property_value: Any, container: "ContainerInterface" = None, set_from_cache: bool = False) -> None: + """Set a property of a setting. + + This will set a property of a specified setting. Since the container stack does not contain + any settings itself, it is required to specify a container to set the property on. The target + container is matched by container type. + + :param key: The key of the setting to set. + :param property_name: The name of the property to set. + :param new_value: The new value to set the property to. + """ + container_index = _ContainerIndexes.UserChanges self._containers[container_index].setProperty(key, property_name, property_value, container, set_from_cache) - ## Overridden from ContainerStack - # - # Since we have a fixed order of containers in the stack and this method would modify the container - # ordering, we disallow this operation. @override(ContainerStack) def addContainer(self, container: ContainerInterface) -> None: + """Overridden from ContainerStack + + Since we have a fixed order of containers in the stack and this method would modify the container + ordering, we disallow this operation. + """ + raise Exceptions.InvalidOperationError("Cannot add a container to Global stack") - ## Overridden from ContainerStack - # - # Since we have a fixed order of containers in the stack and this method would modify the container - # ordering, we disallow this operation. @override(ContainerStack) def insertContainer(self, index: int, container: ContainerInterface) -> None: + """Overridden from ContainerStack + + Since we have a fixed order of containers in the stack and this method would modify the container + ordering, we disallow this operation. + """ + raise Exceptions.InvalidOperationError("Cannot insert a container into Global stack") - ## Overridden from ContainerStack - # - # Since we have a fixed order of containers in the stack and this method would modify the container - # ordering, we disallow this operation. @override(ContainerStack) def removeContainer(self, index: int = 0) -> None: + """Overridden from ContainerStack + + Since we have a fixed order of containers in the stack and this method would modify the container + ordering, we disallow this operation. + """ + raise Exceptions.InvalidOperationError("Cannot remove a container from Global stack") - ## Overridden from ContainerStack - # - # Replaces the container at the specified index with another container. - # This version performs checks to make sure the new container has the expected metadata and type. - # - # \throws Exception.InvalidContainerError Raised when trying to replace a container with a container that has an incorrect type. @override(ContainerStack) def replaceContainer(self, index: int, container: ContainerInterface, postpone_emit: bool = False) -> None: + """Overridden from ContainerStack + + Replaces the container at the specified index with another container. + This version performs checks to make sure the new container has the expected metadata and type. + + :throws Exception.InvalidContainerError Raised when trying to replace a container with a container that has an incorrect type. + """ + expected_type = _ContainerIndexes.IndexTypeMap[index] if expected_type == "definition": if not isinstance(container, DefinitionContainer): @@ -245,16 +293,18 @@ class CuraContainerStack(ContainerStack): super().replaceContainer(index, container, postpone_emit) - ## Overridden from ContainerStack - # - # This deserialize will make sure the internal list of containers matches with what we expect. - # It will first check to see if the container at a certain index already matches with what we - # expect. If it does not, it will search for a matching container with the correct type. Should - # no container with the correct type be found, it will use the empty container. - # - # \throws InvalidContainerStackError Raised when no definition can be found for the stack. @override(ContainerStack) def deserialize(self, serialized: str, file_name: Optional[str] = None) -> str: + """Overridden from ContainerStack + + This deserialize will make sure the internal list of containers matches with what we expect. + It will first check to see if the container at a certain index already matches with what we + expect. If it does not, it will search for a matching container with the correct type. Should + no container with the correct type be found, it will use the empty container. + + :raise InvalidContainerStackError: Raised when no definition can be found for the stack. + """ + # update the serialized data first serialized = super().deserialize(serialized, file_name) @@ -298,10 +348,9 @@ class CuraContainerStack(ContainerStack): ## TODO; Deserialize the containers. return serialized - ## protected: - - # Helper to make sure we emit a PyQt signal on container changes. def _onContainersChanged(self, container: Any) -> None: + """Helper to make sure we emit a PyQt signal on container changes.""" + Application.getInstance().callLater(self.pyqtContainersChanged.emit) # Helper that can be overridden to get the "machine" definition, that is, the definition that defines the machine @@ -309,16 +358,18 @@ class CuraContainerStack(ContainerStack): def _getMachineDefinition(self) -> DefinitionContainer: return self.definition - ## Find the ID that should be used when searching for instance containers for a specified definition. - # - # This handles the situation where the definition specifies we should use a different definition when - # searching for instance containers. - # - # \param machine_definition The definition to find the "quality definition" for. - # - # \return The ID of the definition container to use when searching for instance containers. @classmethod def _findInstanceContainerDefinitionId(cls, machine_definition: DefinitionContainerInterface) -> str: + """Find the ID that should be used when searching for instance containers for a specified definition. + + This handles the situation where the definition specifies we should use a different definition when + searching for instance containers. + + :param machine_definition: The definition to find the "quality definition" for. + + :return: The ID of the definition container to use when searching for instance containers. + """ + quality_definition = machine_definition.getMetaDataEntry("quality_definition") if not quality_definition: return machine_definition.id #type: ignore @@ -330,17 +381,30 @@ class CuraContainerStack(ContainerStack): return cls._findInstanceContainerDefinitionId(definitions[0]) - ## getProperty for extruder positions, with translation from -1 to default extruder number def getExtruderPositionValueWithDefault(self, key): + """getProperty for extruder positions, with translation from -1 to default extruder number""" + value = self.getProperty(key, "value") if value == -1: value = int(Application.getInstance().getMachineManager().defaultExtruderPosition) return value -## private: + def getProperty(self, key: str, property_name: str, context = None) -> Any: + if property_name == "settable_per_extruder": + # Setable per extruder isn't a value that can ever change. So once we requested it once, we can just keep + # that in memory. + try: + return self._settable_per_extruder_cache[key] + except KeyError: + self._settable_per_extruder_cache[key] = super().getProperty(key, property_name, context) + return self._settable_per_extruder_cache[key] + + return super().getProperty(key, property_name, context) + -# Private helper class to keep track of container positions and their types. class _ContainerIndexes: + """Private helper class to keep track of container positions and their types.""" + UserChanges = 0 QualityChanges = 1 Intent = 2 diff --git a/cura/Settings/CuraStackBuilder.py b/cura/Settings/CuraStackBuilder.py index 61a04e1be6..efc447b2cf 100644 --- a/cura/Settings/CuraStackBuilder.py +++ b/cura/Settings/CuraStackBuilder.py @@ -9,22 +9,24 @@ from UM.Settings.Interfaces import DefinitionContainerInterface from UM.Settings.InstanceContainer import InstanceContainer from cura.Machines.ContainerTree import ContainerTree -from cura.Machines.MachineNode import MachineNode from .GlobalStack import GlobalStack from .ExtruderStack import ExtruderStack -## Contains helper functions to create new machines. class CuraStackBuilder: + """Contains helper functions to create new machines.""" - ## Create a new instance of a machine. - # - # \param name The name of the new machine. - # \param definition_id The ID of the machine definition to use. - # - # \return The new global stack or None if an error occurred. @classmethod - def createMachine(cls, name: str, definition_id: str) -> Optional[GlobalStack]: + def createMachine(cls, name: str, definition_id: str, machine_extruder_count: Optional[int] = None) -> Optional[GlobalStack]: + """Create a new instance of a machine. + + :param name: The name of the new machine. + :param definition_id: The ID of the machine definition to use. + :param machine_extruder_count: The number of extruders in the machine. + + :return: The new global stack or None if an error occurred. + """ + from cura.CuraApplication import CuraApplication application = CuraApplication.getInstance() registry = application.getContainerRegistry() @@ -58,9 +60,20 @@ class CuraStackBuilder: # Create ExtruderStacks extruder_dict = machine_definition.getMetaDataEntry("machine_extruder_trains") for position in extruder_dict: - cls.createExtruderStackWithDefaultSetup(new_global_stack, position) - - for new_extruder in new_global_stack.extruders.values(): # Only register the extruders if we're sure that all of them are correct. + try: + cls.createExtruderStackWithDefaultSetup(new_global_stack, position) + except IndexError as e: + Logger.logException("e", "Failed to create an extruder stack for position {pos}: {err}".format(pos = position, err = str(e))) + return None + + # If given, set the machine_extruder_count when creating the machine, or else the extruderList used bellow will + # not return the correct extruder list (since by default, the machine_extruder_count is 1) in machines with + # settable number of extruders. + if machine_extruder_count and 0 <= machine_extruder_count <= len(extruder_dict): + new_global_stack.setProperty("machine_extruder_count", "value", machine_extruder_count) + + # Only register the extruders if we're sure that all of them are correct. + for new_extruder in new_global_stack.extruderList: registry.addContainer(new_extruder) # Register the global stack after the extruder stacks are created. This prevents the registry from adding another @@ -69,12 +82,14 @@ class CuraStackBuilder: return new_global_stack - ## Create a default Extruder Stack - # - # \param global_stack The global stack this extruder refers to. - # \param extruder_position The position of the current extruder. @classmethod def createExtruderStackWithDefaultSetup(cls, global_stack: "GlobalStack", extruder_position: int) -> None: + """Create a default Extruder Stack + + :param global_stack: The global stack this extruder refers to. + :param extruder_position: The position of the current extruder. + """ + from cura.CuraApplication import CuraApplication application = CuraApplication.getInstance() registry = application.getContainerRegistry() @@ -118,17 +133,6 @@ class CuraStackBuilder: registry.addContainer(new_extruder) - ## Create a new Extruder stack - # - # \param new_stack_id The ID of the new stack. - # \param extruder_definition The definition to base the new stack on. - # \param machine_definition_id The ID of the machine definition to use for the user container. - # \param position The position the extruder occupies in the machine. - # \param variant_container The variant selected for the current extruder. - # \param material_container The material selected for the current extruder. - # \param quality_container The quality selected for the current extruder. - # - # \return A new Extruder stack instance with the specified parameters. @classmethod def createExtruderStack(cls, new_stack_id: str, extruder_definition: DefinitionContainerInterface, machine_definition_id: str, @@ -137,6 +141,19 @@ class CuraStackBuilder: material_container: "InstanceContainer", quality_container: "InstanceContainer") -> ExtruderStack: + """Create a new Extruder stack + + :param new_stack_id: The ID of the new stack. + :param extruder_definition: The definition to base the new stack on. + :param machine_definition_id: The ID of the machine definition to use for the user container. + :param position: The position the extruder occupies in the machine. + :param variant_container: The variant selected for the current extruder. + :param material_container: The material selected for the current extruder. + :param quality_container: The quality selected for the current extruder. + + :return: A new Extruder stack instance with the specified parameters. + """ + from cura.CuraApplication import CuraApplication application = CuraApplication.getInstance() registry = application.getContainerRegistry() @@ -165,29 +182,23 @@ class CuraStackBuilder: return stack - ## Create a new Global stack - # - # \param new_stack_id The ID of the new stack. - # \param definition The definition to base the new stack on. - # \param kwargs You can add keyword arguments to specify IDs of containers to use for a specific type, for example "variant": "0.4mm" - # - # \return A new Global stack instance with the specified parameters. - - ## Create a new Global stack - # - # \param new_stack_id The ID of the new stack. - # \param definition The definition to base the new stack on. - # \param variant_container The variant selected for the current stack. - # \param material_container The material selected for the current stack. - # \param quality_container The quality selected for the current stack. - # - # \return A new Global stack instance with the specified parameters. @classmethod def createGlobalStack(cls, new_stack_id: str, definition: DefinitionContainerInterface, variant_container: "InstanceContainer", material_container: "InstanceContainer", quality_container: "InstanceContainer") -> GlobalStack: + """Create a new Global stack + + :param new_stack_id: The ID of the new stack. + :param definition: The definition to base the new stack on. + :param variant_container: The variant selected for the current stack. + :param material_container: The material selected for the current stack. + :param quality_container: The quality selected for the current stack. + + :return: A new Global stack instance with the specified parameters. + """ + from cura.CuraApplication import CuraApplication application = CuraApplication.getInstance() registry = application.getContainerRegistry() diff --git a/cura/Settings/Exceptions.py b/cura/Settings/Exceptions.py index 0a869cf922..fbb130417c 100644 --- a/cura/Settings/Exceptions.py +++ b/cura/Settings/Exceptions.py @@ -2,21 +2,25 @@ # Cura is released under the terms of the LGPLv3 or higher. -## Raised when trying to perform an operation like add on a stack that does not allow that. class InvalidOperationError(Exception): + """Raised when trying to perform an operation like add on a stack that does not allow that.""" + pass -## Raised when trying to replace a container with a container that does not have the expected type. class InvalidContainerError(Exception): + """Raised when trying to replace a container with a container that does not have the expected type.""" + pass -## Raised when trying to add an extruder to a Global stack that already has the maximum number of extruders. class TooManyExtrudersError(Exception): + """Raised when trying to add an extruder to a Global stack that already has the maximum number of extruders.""" + pass -## Raised when an extruder has no next stack set. class NoGlobalStackError(Exception): + """Raised when an extruder has no next stack set.""" + pass diff --git a/cura/Settings/ExtruderManager.py b/cura/Settings/ExtruderManager.py index e0ec6c4d14..1e9199d525 100755 --- a/cura/Settings/ExtruderManager.py +++ b/cura/Settings/ExtruderManager.py @@ -19,13 +19,15 @@ if TYPE_CHECKING: from cura.Settings.ExtruderStack import ExtruderStack -## Manages all existing extruder stacks. -# -# This keeps a list of extruder stacks for each machine. class ExtruderManager(QObject): + """Manages all existing extruder stacks. + + This keeps a list of extruder stacks for each machine. + """ - ## Registers listeners and such to listen to changes to the extruders. def __init__(self, parent = None): + """Registers listeners and such to listen to changes to the extruders.""" + if ExtruderManager.__instance is not None: raise RuntimeError("Try to create singleton '%s' more than once" % self.__class__.__name__) ExtruderManager.__instance = self @@ -43,20 +45,22 @@ class ExtruderManager(QObject): Selection.selectionChanged.connect(self.resetSelectedObjectExtruders) - ## Signal to notify other components when the list of extruders for a machine definition changes. extrudersChanged = pyqtSignal(QVariant) + """Signal to notify other components when the list of extruders for a machine definition changes.""" - ## Notify when the user switches the currently active extruder. activeExtruderChanged = pyqtSignal() + """Notify when the user switches the currently active extruder.""" - ## Gets the unique identifier of the currently active extruder stack. - # - # The currently active extruder stack is the stack that is currently being - # edited. - # - # \return The unique ID of the currently active extruder stack. @pyqtProperty(str, notify = activeExtruderChanged) def activeExtruderStackId(self) -> Optional[str]: + """Gets the unique identifier of the currently active extruder stack. + + The currently active extruder stack is the stack that is currently being + edited. + + :return: The unique ID of the currently active extruder stack. + """ + if not self._application.getGlobalContainerStack(): return None # No active machine, so no active extruder. try: @@ -64,9 +68,10 @@ class ExtruderManager(QObject): except KeyError: # Extruder index could be -1 if the global tab is selected, or the entry doesn't exist if the machine definition is wrong. return None - ## Gets a dict with the extruder stack ids with the extruder number as the key. @pyqtProperty("QVariantMap", notify = extrudersChanged) def extruderIds(self) -> Dict[str, str]: + """Gets a dict with the extruder stack ids with the extruder number as the key.""" + extruder_stack_ids = {} # type: Dict[str, str] global_container_stack = self._application.getGlobalContainerStack() @@ -75,11 +80,13 @@ class ExtruderManager(QObject): return extruder_stack_ids - ## Changes the active extruder by index. - # - # \param index The index of the new active extruder. @pyqtSlot(int) def setActiveExtruderIndex(self, index: int) -> None: + """Changes the active extruder by index. + + :param index: The index of the new active extruder. + """ + if self._active_extruder_index != index: self._active_extruder_index = index self.activeExtruderChanged.emit() @@ -88,12 +95,13 @@ class ExtruderManager(QObject): def activeExtruderIndex(self) -> int: return self._active_extruder_index - ## Emitted whenever the selectedObjectExtruders property changes. selectedObjectExtrudersChanged = pyqtSignal() + """Emitted whenever the selectedObjectExtruders property changes.""" - ## Provides a list of extruder IDs used by the current selected objects. @pyqtProperty("QVariantList", notify = selectedObjectExtrudersChanged) def selectedObjectExtruders(self) -> List[Union[str, "ExtruderStack"]]: + """Provides a list of extruder IDs used by the current selected objects.""" + if not self._selected_object_extruders: object_extruders = set() @@ -122,11 +130,13 @@ class ExtruderManager(QObject): return self._selected_object_extruders - ## Reset the internal list used for the selectedObjectExtruders property - # - # This will trigger a recalculation of the extruders used for the - # selection. def resetSelectedObjectExtruders(self) -> None: + """Reset the internal list used for the selectedObjectExtruders property + + This will trigger a recalculation of the extruders used for the + selection. + """ + self._selected_object_extruders = [] self.selectedObjectExtrudersChanged.emit() @@ -134,8 +144,9 @@ class ExtruderManager(QObject): def getActiveExtruderStack(self) -> Optional["ExtruderStack"]: return self.getExtruderStack(self.activeExtruderIndex) - ## Get an extruder stack by index def getExtruderStack(self, index) -> Optional["ExtruderStack"]: + """Get an extruder stack by index""" + global_container_stack = self._application.getGlobalContainerStack() if global_container_stack: if global_container_stack.getId() in self._extruder_trains: @@ -143,31 +154,14 @@ class ExtruderManager(QObject): return self._extruder_trains[global_container_stack.getId()][str(index)] return None - def registerExtruder(self, extruder_train: "ExtruderStack", machine_id: str) -> None: - changed = False - - if machine_id not in self._extruder_trains: - self._extruder_trains[machine_id] = {} - changed = True - - # do not register if an extruder has already been registered at the position on this machine - if any(item.getId() == extruder_train.getId() for item in self._extruder_trains[machine_id].values()): - Logger.log("w", "Extruder [%s] has already been registered on machine [%s], not doing anything", - extruder_train.getId(), machine_id) - return - - if extruder_train: - self._extruder_trains[machine_id][extruder_train.getMetaDataEntry("position")] = extruder_train - changed = True - if changed: - self.extrudersChanged.emit(machine_id) - - ## Gets a property of a setting for all extruders. - # - # \param setting_key \type{str} The setting to get the property of. - # \param property \type{str} The property to get. - # \return \type{List} the list of results def getAllExtruderSettings(self, setting_key: str, prop: str) -> List[Any]: + """Gets a property of a setting for all extruders. + + :param setting_key: :type{str} The setting to get the property of. + :param prop: :type{str} The property to get. + :return: :type{List} the list of results + """ + result = [] for extruder_stack in self.getActiveExtruderStacks(): @@ -182,17 +176,19 @@ class ExtruderManager(QObject): else: return value - ## Gets the extruder stacks that are actually being used at the moment. - # - # An extruder stack is being used if it is the extruder to print any mesh - # with, or if it is the support infill extruder, the support interface - # extruder, or the bed adhesion extruder. - # - # If there are no extruders, this returns the global stack as a singleton - # list. - # - # \return A list of extruder stacks. def getUsedExtruderStacks(self) -> List["ExtruderStack"]: + """Gets the extruder stacks that are actually being used at the moment. + + An extruder stack is being used if it is the extruder to print any mesh + with, or if it is the support infill extruder, the support interface + extruder, or the bed adhesion extruder. + + If there are no extruders, this returns the global stack as a singleton + list. + + :return: A list of extruder stacks. + """ + global_stack = self._application.getGlobalContainerStack() container_registry = ContainerRegistry.getInstance() @@ -208,47 +204,50 @@ class ExtruderManager(QObject): # If no extruders are registered in the extruder manager yet, return an empty array if len(self.extruderIds) == 0: return [] + number_active_extruders = len([extruder for extruder in self.getActiveExtruderStacks() if extruder.isEnabled]) # Get the extruders of all printable meshes in the scene - meshes = [node for node in DepthFirstIterator(scene_root) if isinstance(node, SceneNode) and node.isSelectable()] #type: ignore #Ignore type error because iter() should get called automatically by Python syntax. + nodes = [node for node in DepthFirstIterator(scene_root) if node.isSelectable() and not node.callDecoration("isAntiOverhangMesh") and not node.callDecoration("isSupportMesh")] #type: ignore #Ignore type error because iter() should get called automatically by Python syntax. - # Exclude anti-overhang meshes - mesh_list = [] - for mesh in meshes: - stack = mesh.callDecoration("getStack") - if stack is not None and (stack.getProperty("anti_overhang_mesh", "value") or stack.getProperty("support_mesh", "value")): - continue - mesh_list.append(mesh) - - for mesh in mesh_list: - extruder_stack_id = mesh.callDecoration("getActiveExtruder") + for node in nodes: + extruder_stack_id = node.callDecoration("getActiveExtruder") if not extruder_stack_id: # No per-object settings for this node extruder_stack_id = self.extruderIds["0"] used_extruder_stack_ids.add(extruder_stack_id) + if len(used_extruder_stack_ids) == number_active_extruders: + # We're already done. Stop looking. + # Especially with a lot of models on the buildplate, this will speed up things rather dramatically. + break + # Get whether any of them use support. - stack_to_use = mesh.callDecoration("getStack") # if there is a per-mesh stack, we use it + stack_to_use = node.callDecoration("getStack") # if there is a per-mesh stack, we use it if not stack_to_use: # if there is no per-mesh stack, we use the build extruder for this mesh stack_to_use = container_registry.findContainerStacks(id = extruder_stack_id)[0] - support_enabled |= stack_to_use.getProperty("support_enable", "value") - support_bottom_enabled |= stack_to_use.getProperty("support_bottom_enable", "value") - support_roof_enabled |= stack_to_use.getProperty("support_roof_enable", "value") - - # Check limit to extruders - limit_to_extruder_feature_list = ["wall_0_extruder_nr", - "wall_x_extruder_nr", - "roofing_extruder_nr", - "top_bottom_extruder_nr", - "infill_extruder_nr", - ] - for extruder_nr_feature_name in limit_to_extruder_feature_list: - extruder_nr = int(global_stack.getProperty(extruder_nr_feature_name, "value")) - if extruder_nr == -1: - continue - used_extruder_stack_ids.add(self.extruderIds[str(extruder_nr)]) + if not support_enabled: + support_enabled |= stack_to_use.getProperty("support_enable", "value") + if not support_bottom_enabled: + support_bottom_enabled |= stack_to_use.getProperty("support_bottom_enable", "value") + if not support_roof_enabled: + support_roof_enabled |= stack_to_use.getProperty("support_roof_enable", "value") + + # Check limit to extruders + limit_to_extruder_feature_list = ["wall_0_extruder_nr", + "wall_x_extruder_nr", + "roofing_extruder_nr", + "top_bottom_extruder_nr", + "infill_extruder_nr", + ] + for extruder_nr_feature_name in limit_to_extruder_feature_list: + extruder_nr = int(global_stack.getProperty(extruder_nr_feature_name, "value")) + if extruder_nr == -1: + continue + if str(extruder_nr) not in self.extruderIds: + extruder_nr = int(self._application.getMachineManager().defaultExtruderPosition) + used_extruder_stack_ids.add(self.extruderIds[str(extruder_nr)]) # Check support extruders if support_enabled: @@ -275,11 +274,13 @@ class ExtruderManager(QObject): Logger.log("e", "Unable to find one or more of the extruders in %s", used_extruder_stack_ids) return [] - ## Get the extruder that the print will start with. - # - # This should mirror the implementation in CuraEngine of - # ``FffGcodeWriter::getStartExtruder()``. def getInitialExtruderNr(self) -> int: + """Get the extruder that the print will start with. + + This should mirror the implementation in CuraEngine of + ``FffGcodeWriter::getStartExtruder()``. + """ + application = cura.CuraApplication.CuraApplication.getInstance() global_stack = application.getGlobalContainerStack() @@ -288,34 +289,41 @@ class ExtruderManager(QObject): return global_stack.getProperty("adhesion_extruder_nr", "value") # No adhesion? Well maybe there is still support brim. - if (global_stack.getProperty("support_enable", "value") or global_stack.getProperty("support_tree_enable", "value")) 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"): return global_stack.getProperty("support_infill_extruder_nr", "value") # REALLY no adhesion? Use the first used extruder. return self.getUsedExtruderStacks()[0].getProperty("extruder_nr", "value") - ## Removes the container stack and user profile for the extruders for a specific machine. - # - # \param machine_id The machine to remove the extruders for. def removeMachineExtruders(self, machine_id: str) -> None: + """Removes the container stack and user profile for the extruders for a specific machine. + + :param machine_id: The machine to remove the extruders for. + """ + for extruder in self.getMachineExtruders(machine_id): ContainerRegistry.getInstance().removeContainer(extruder.userChanges.getId()) + ContainerRegistry.getInstance().removeContainer(extruder.definitionChanges.getId()) ContainerRegistry.getInstance().removeContainer(extruder.getId()) if machine_id in self._extruder_trains: del self._extruder_trains[machine_id] - ## Returns extruders for a specific machine. - # - # \param machine_id The machine to get the extruders of. def getMachineExtruders(self, machine_id: str) -> List["ExtruderStack"]: + """Returns extruders for a specific machine. + + :param machine_id: The machine to get the extruders of. + """ + if machine_id not in self._extruder_trains: return [] return [self._extruder_trains[machine_id][name] for name in self._extruder_trains[machine_id]] - ## Returns the list of active extruder stacks, taking into account the machine extruder count. - # - # \return \type{List[ContainerStack]} a list of def getActiveExtruderStacks(self) -> List["ExtruderStack"]: + """Returns the list of active extruder stacks, taking into account the machine extruder count. + + :return: :type{List[ContainerStack]} a list of + """ + global_stack = self._application.getGlobalContainerStack() if not global_stack: return [] @@ -327,8 +335,9 @@ class ExtruderManager(QObject): self.resetSelectedObjectExtruders() - ## Adds the extruders to the selected machine. def addMachineExtruders(self, global_stack: GlobalStack) -> None: + """Adds the extruders to the selected machine.""" + extruders_changed = False container_registry = ContainerRegistry.getInstance() global_stack_id = global_stack.getId() @@ -394,26 +403,30 @@ class ExtruderManager(QObject): raise IndexError(msg) extruder_stack_0.definition = extruder_definition - ## Get all extruder values for a certain setting. - # - # This is exposed to qml for display purposes - # - # \param key The key of the setting to retrieve values for. - # - # \return String representing the extruder values @pyqtSlot(str, result="QVariant") def getInstanceExtruderValues(self, key: str) -> List: + """Get all extruder values for a certain setting. + + This is exposed to qml for display purposes + + :param key: The key of the setting to retrieve values for. + + :return: String representing the extruder values + """ + return self._application.getCuraFormulaFunctions().getValuesInAllExtruders(key) - ## Get the resolve value or value for a given key - # - # This is the effective value for a given key, it is used for values in the global stack. - # This is exposed to SettingFunction to use in value functions. - # \param key The key of the setting to get the value of. - # - # \return The effective value @staticmethod def getResolveOrValue(key: str) -> Any: + """Get the resolve value or value for a given key + + This is the effective value for a given key, it is used for values in the global stack. + This is exposed to SettingFunction to use in value functions. + :param key: The key of the setting to get the value of. + + :return: The effective value + """ + global_stack = cast(GlobalStack, cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()) resolved_value = global_stack.getProperty(key, "value") diff --git a/cura/Settings/ExtruderStack.py b/cura/Settings/ExtruderStack.py index 5d4b3e38b1..2a9838c671 100644 --- a/cura/Settings/ExtruderStack.py +++ b/cura/Settings/ExtruderStack.py @@ -22,10 +22,9 @@ if TYPE_CHECKING: from cura.Settings.GlobalStack import GlobalStack -## Represents an Extruder and its related containers. -# -# class ExtruderStack(CuraContainerStack): + """Represents an Extruder and its related containers.""" + def __init__(self, container_id: str) -> None: super().__init__(container_id) @@ -33,20 +32,21 @@ class ExtruderStack(CuraContainerStack): self.propertiesChanged.connect(self._onPropertiesChanged) + self.setDirty(False) + enabledChanged = pyqtSignal() - ## Overridden from ContainerStack - # - # This will set the next stack and ensure that we register this stack as an extruder. @override(ContainerStack) def setNextStack(self, stack: CuraContainerStack, connect_signals: bool = True) -> None: + """Overridden from ContainerStack + + This will set the next stack and ensure that we register this stack as an extruder. + """ + super().setNextStack(stack) stack.addExtruder(self) self.setMetaDataEntry("machine", stack.id) - # For backward compatibility: Register the extruder with the Extruder Manager - ExtruderManager.getInstance().registerExtruder(self, stack.id) - @override(ContainerStack) def getNextStack(self) -> Optional["GlobalStack"]: return super().getNextStack() @@ -71,11 +71,13 @@ class ExtruderStack(CuraContainerStack): compatibleMaterialDiameterChanged = pyqtSignal() - ## Return the filament diameter that the machine requires. - # - # If the machine has no requirement for the diameter, -1 is returned. - # \return The filament diameter for the printer def getCompatibleMaterialDiameter(self) -> float: + """Return the filament diameter that the machine requires. + + If the machine has no requirement for the diameter, -1 is returned. + :return: The filament diameter for the printer + """ + context = PropertyEvaluationContext(self) context.context["evaluate_from_container_index"] = _ContainerIndexes.Variant @@ -97,41 +99,45 @@ class ExtruderStack(CuraContainerStack): approximateMaterialDiameterChanged = pyqtSignal() - ## Return the approximate filament diameter that the machine requires. - # - # The approximate material diameter is the material diameter rounded to - # the nearest millimetre. - # - # If the machine has no requirement for the diameter, -1 is returned. - # - # \return The approximate filament diameter for the printer def getApproximateMaterialDiameter(self) -> float: + """Return the approximate filament diameter that the machine requires. + + The approximate material diameter is the material diameter rounded to + the nearest millimetre. + + If the machine has no requirement for the diameter, -1 is returned. + + :return: The approximate filament diameter for the printer + """ + return round(self.getCompatibleMaterialDiameter()) approximateMaterialDiameter = pyqtProperty(float, fget = getApproximateMaterialDiameter, notify = approximateMaterialDiameterChanged) - ## Overridden from ContainerStack - # - # It will perform a few extra checks when trying to get properties. - # - # The two extra checks it currently does is to ensure a next stack is set and to bypass - # the extruder when the property is not settable per extruder. - # - # \throws Exceptions.NoGlobalStackError Raised when trying to get a property from an extruder without - # having a next stack set. @override(ContainerStack) def getProperty(self, key: str, property_name: str, context: Optional[PropertyEvaluationContext] = None) -> Any: + """Overridden from ContainerStack + + It will perform a few extra checks when trying to get properties. + + The two extra checks it currently does is to ensure a next stack is set and to bypass + the extruder when the property is not settable per extruder. + + :throws Exceptions.NoGlobalStackError Raised when trying to get a property from an extruder without + having a next stack set. + """ + if not self._next_stack: raise Exceptions.NoGlobalStackError("Extruder {id} is missing the next stack!".format(id = self.id)) - if context is None: - context = PropertyEvaluationContext() - context.pushContainer(self) + if context: + context.pushContainer(self) if not super().getProperty(key, "settable_per_extruder", context): result = self.getNextStack().getProperty(key, property_name, context) - context.popContainer() + if context: + context.popContainer() return result limit_to_extruder = super().getProperty(key, "limit_to_extruder", context) @@ -144,13 +150,15 @@ class ExtruderStack(CuraContainerStack): try: result = self.getNextStack().extruderList[int(limit_to_extruder)].getProperty(key, property_name, context) if result is not None: - context.popContainer() + if context: + context.popContainer() return result except IndexError: pass result = super().getProperty(key, property_name, context) - context.popContainer() + if context: + context.popContainer() return result @override(CuraContainerStack) diff --git a/cura/Settings/GlobalStack.py b/cura/Settings/GlobalStack.py index d3a8842aa3..2c7cbf5e25 100755 --- a/cura/Settings/GlobalStack.py +++ b/cura/Settings/GlobalStack.py @@ -29,9 +29,9 @@ if TYPE_CHECKING: from cura.Settings.ExtruderStack import ExtruderStack -## Represents the Global or Machine stack and its related containers. -# class GlobalStack(CuraContainerStack): + """Represents the Global or Machine stack and its related containers.""" + def __init__(self, container_id: str) -> None: super().__init__(container_id) @@ -55,15 +55,19 @@ class GlobalStack(CuraContainerStack): # properties. So we need to tie them together like this. self.metaDataChanged.connect(self.configuredConnectionTypesChanged) + self.setDirty(False) + extrudersChanged = pyqtSignal() configuredConnectionTypesChanged = pyqtSignal() - ## Get the list of extruders of this stack. - # - # \return The extruders registered with this stack. @pyqtProperty("QVariantMap", notify = extrudersChanged) @deprecated("Please use extruderList instead.", "4.4") def extruders(self) -> Dict[str, "ExtruderStack"]: + """Get the list of extruders of this stack. + + :return: The extruders registered with this stack. + """ + return self._extruders @pyqtProperty("QVariantList", notify = extrudersChanged) @@ -86,16 +90,18 @@ class GlobalStack(CuraContainerStack): def getLoadingPriority(cls) -> int: return 2 - ## The configured connection types can be used to find out if the global - # stack is configured to be connected with a printer, without having to - # know all the details as to how this is exactly done (and without - # actually setting the stack to be active). - # - # This data can then in turn also be used when the global stack is active; - # If we can't get a network connection, but it is configured to have one, - # we can display a different icon to indicate the difference. @pyqtProperty("QVariantList", notify=configuredConnectionTypesChanged) def configuredConnectionTypes(self) -> List[int]: + """The configured connection types can be used to find out if the global + + stack is configured to be connected with a printer, without having to + know all the details as to how this is exactly done (and without + actually setting the stack to be active). + + This data can then in turn also be used when the global stack is active; + If we can't get a network connection, but it is configured to have one, + we can display a different icon to indicate the difference. + """ # Requesting it from the metadata actually gets them as strings (as that's what you get from serializing). # But we do want them returned as a list of ints (so the rest of the code can directly compare) connection_types = self.getMetaDataEntry("connection_type", "").split(",") @@ -122,16 +128,18 @@ class GlobalStack(CuraContainerStack): ConnectionType.CloudConnection.value] return has_remote_connection - ## \sa configuredConnectionTypes def addConfiguredConnectionType(self, connection_type: int) -> None: + """:sa configuredConnectionTypes""" + configured_connection_types = self.configuredConnectionTypes if connection_type not in configured_connection_types: # Store the values as a string. configured_connection_types.append(connection_type) self.setMetaDataEntry("connection_type", ",".join([str(c_type) for c_type in configured_connection_types])) - ## \sa configuredConnectionTypes def removeConfiguredConnectionType(self, connection_type: int) -> None: + """:sa configuredConnectionTypes""" + configured_connection_types = self.configuredConnectionTypes if connection_type in configured_connection_types: # Store the values as a string. @@ -163,13 +171,15 @@ class GlobalStack(CuraContainerStack): def preferred_output_file_formats(self) -> str: return self.getMetaDataEntry("file_formats") - ## Add an extruder to the list of extruders of this stack. - # - # \param extruder The extruder to add. - # - # \throws Exceptions.TooManyExtrudersError Raised when trying to add an extruder while we - # already have the maximum number of extruders. def addExtruder(self, extruder: ContainerStack) -> None: + """Add an extruder to the list of extruders of this stack. + + :param extruder: The extruder to add. + + :raise Exceptions.TooManyExtrudersError: Raised when trying to add an extruder while we + already have the maximum number of extruders. + """ + position = extruder.getMetaDataEntry("position") if position is None: Logger.log("w", "No position defined for extruder {extruder}, cannot add it to stack {stack}", extruder = extruder.id, stack = self.id) @@ -183,25 +193,26 @@ class GlobalStack(CuraContainerStack): self.extrudersChanged.emit() Logger.log("i", "Extruder[%s] added to [%s] at position [%s]", extruder.id, self.id, position) - ## Overridden from ContainerStack - # - # This will return the value of the specified property for the specified setting, - # unless the property is "value" and that setting has a "resolve" function set. - # When a resolve is set, it will instead try and execute the resolve first and - # then fall back to the normal "value" property. - # - # \param key The setting key to get the property of. - # \param property_name The property to get the value of. - # - # \return The value of the property for the specified setting, or None if not found. @override(ContainerStack) def getProperty(self, key: str, property_name: str, context: Optional[PropertyEvaluationContext] = None) -> Any: + """Overridden from ContainerStack + + This will return the value of the specified property for the specified setting, + unless the property is "value" and that setting has a "resolve" function set. + When a resolve is set, it will instead try and execute the resolve first and + then fall back to the normal "value" property. + + :param key: The setting key to get the property of. + :param property_name: The property to get the value of. + + :return: The value of the property for the specified setting, or None if not found. + """ + if not self.definition.findDefinitions(key = key): return None - if context is None: - context = PropertyEvaluationContext() - context.pushContainer(self) + if context: + context.pushContainer(self) # Handle the "resolve" property. #TODO: Why the hell does this involve threading? @@ -226,23 +237,25 @@ class GlobalStack(CuraContainerStack): if super().getProperty(key, "settable_per_extruder", context): result = self._extruders[str(limit_to_extruder)].getProperty(key, property_name, context) if result is not None: - context.popContainer() + if context: + context.popContainer() return result else: Logger.log("e", "Setting {setting} has limit_to_extruder but is not settable per extruder!", setting = key) result = super().getProperty(key, property_name, context) - context.popContainer() + if context: + context.popContainer() return result - ## Overridden from ContainerStack - # - # This will simply raise an exception since the Global stack cannot have a next stack. @override(ContainerStack) def setNextStack(self, stack: CuraContainerStack, connect_signals: bool = True) -> None: - raise Exceptions.InvalidOperationError("Global stack cannot have a next stack!") + """Overridden from ContainerStack - # protected: + This will simply raise an exception since the Global stack cannot have a next stack. + """ + + raise Exceptions.InvalidOperationError("Global stack cannot have a next stack!") # Determine whether or not we should try to get the "resolve" property instead of the # requested property. @@ -251,6 +264,10 @@ class GlobalStack(CuraContainerStack): # Do not try to resolve anything but the "value" property return False + if not self.definition.getProperty(key, "resolve"): + # If there isn't a resolve set for this setting, there isn't anything to do here. + return False + current_thread = threading.current_thread() if key in self._resolving_settings[current_thread.name]: # To prevent infinite recursion, if getProperty is called with the same key as @@ -259,17 +276,17 @@ class GlobalStack(CuraContainerStack): # track all settings that are being resolved. return False - setting_state = super().getProperty(key, "state", context = context) - if setting_state is not None and setting_state != InstanceState.Default: - # When the user has explicitly set a value, we should ignore any resolve and - # just return that value. + if self.hasUserValue(key): + # When the user has explicitly set a value, we should ignore any resolve and just return that value. return False return True - ## Perform some sanity checks on the global stack - # Sanity check for extruders; they must have positions 0 and up to machine_extruder_count - 1 def isValid(self) -> bool: + """Perform some sanity checks on the global stack + + Sanity check for extruders; they must have positions 0 and up to machine_extruder_count - 1 + """ container_registry = ContainerRegistry.getInstance() extruder_trains = container_registry.findContainerStacks(type = "extruder_train", machine = self.getId()) @@ -299,9 +316,10 @@ class GlobalStack(CuraContainerStack): def hasVariantBuildplates(self) -> bool: return parseBool(self.getMetaDataEntry("has_variant_buildplates", False)) - ## Get default firmware file name if one is specified in the firmware @pyqtSlot(result = str) def getDefaultFirmwareName(self) -> str: + """Get default firmware file name if one is specified in the firmware""" + machine_has_heated_bed = self.getProperty("machine_heated_bed", "value") baudrate = 250000 diff --git a/cura/Settings/IntentManager.py b/cura/Settings/IntentManager.py index 5133b401b4..a556a86dd8 100644 --- a/cura/Settings/IntentManager.py +++ b/cura/Settings/IntentManager.py @@ -2,7 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot -from typing import Any, Dict, List, Optional, Set, Tuple, TYPE_CHECKING +from typing import Any, Dict, List, Set, Tuple, TYPE_CHECKING from UM.Logger import Logger from UM.Settings.InstanceContainer import InstanceContainer @@ -15,29 +15,32 @@ if TYPE_CHECKING: from UM.Settings.InstanceContainer import InstanceContainer -## Front-end for querying which intents are available for a certain -# configuration. class IntentManager(QObject): + """Front-end for querying which intents are available for a certain configuration. + """ __instance = None - ## This class is a singleton. @classmethod def getInstance(cls): + """This class is a singleton.""" + if not cls.__instance: cls.__instance = IntentManager() return cls.__instance intentCategoryChanged = pyqtSignal() #Triggered when we switch categories. - ## Gets the metadata dictionaries of all intent profiles for a given - # configuration. - # - # \param definition_id ID of the printer. - # \param nozzle_name Name of the nozzle. - # \param material_base_file The base_file of the material. - # \return A list of metadata dictionaries matching the search criteria, or - # an empty list if nothing was found. def intentMetadatas(self, definition_id: str, nozzle_name: str, material_base_file: str) -> List[Dict[str, Any]]: + """Gets the metadata dictionaries of all intent profiles for a given + + configuration. + + :param definition_id: ID of the printer. + :param nozzle_name: Name of the nozzle. + :param material_base_file: The base_file of the material. + :return: A list of metadata dictionaries matching the search criteria, or + an empty list if nothing was found. + """ intent_metadatas = [] # type: List[Dict[str, Any]] try: materials = ContainerTree.getInstance().machines[definition_id].variants[nozzle_name].materials @@ -53,28 +56,32 @@ class IntentManager(QObject): intent_metadatas.append(intent_node.getMetadata()) return intent_metadatas - ## Collects and returns all intent categories available for the given - # parameters. Note that the 'default' category is always available. - # - # \param definition_id ID of the printer. - # \param nozzle_name Name of the nozzle. - # \param material_id ID of the material. - # \return A set of intent category names. def intentCategories(self, definition_id: str, nozzle_id: str, material_id: str) -> List[str]: + """Collects and returns all intent categories available for the given + + parameters. Note that the 'default' category is always available. + + :param definition_id: ID of the printer. + :param nozzle_name: Name of the nozzle. + :param material_id: ID of the material. + :return: A set of intent category names. + """ categories = set() for intent in self.intentMetadatas(definition_id, nozzle_id, material_id): categories.add(intent["intent_category"]) categories.add("default") #The "empty" intent is not an actual profile specific to the configuration but we do want it to appear in the categories list. return list(categories) - ## List of intents to be displayed in the interface. - # - # For the interface this will have to be broken up into the different - # intent categories. That is up to the model there. - # - # \return A list of tuples of intent_category and quality_type. The actual - # instance may vary per extruder. def getCurrentAvailableIntents(self) -> List[Tuple[str, str]]: + """List of intents to be displayed in the interface. + + For the interface this will have to be broken up into the different + intent categories. That is up to the model there. + + :return: A list of tuples of intent_category and quality_type. The actual + instance may vary per extruder. + """ + application = cura.CuraApplication.CuraApplication.getInstance() global_stack = application.getGlobalContainerStack() if global_stack is None: @@ -100,16 +107,18 @@ class IntentManager(QObject): result.add((intent_metadata["intent_category"], intent_metadata["quality_type"])) return list(result) - ## List of intent categories available in either of the extruders. - # - # This is purposefully inconsistent with the way that the quality types - # are listed. The quality types will show all quality types available in - # the printer using any configuration. This will only list the intent - # categories that are available using the current configuration (but the - # union over the extruders). - # \return List of all categories in the current configurations of all - # extruders. def currentAvailableIntentCategories(self) -> List[str]: + """List of intent categories available in either of the extruders. + + This is purposefully inconsistent with the way that the quality types + are listed. The quality types will show all quality types available in + the printer using any configuration. This will only list the intent + categories that are available using the current configuration (but the + union over the extruders). + :return: List of all categories in the current configurations of all + extruders. + """ + global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() if global_stack is None: return ["default"] @@ -123,10 +132,12 @@ class IntentManager(QObject): final_intent_categories.update(self.intentCategories(current_definition_id, nozzle_name, material_id)) return list(final_intent_categories) - ## The intent that gets selected by default when no intent is available for - # the configuration, an extruder can't match the intent that the user - # selects, or just when creating a new printer. def getDefaultIntent(self) -> "InstanceContainer": + """The intent that gets selected by default when no intent is available for + + the configuration, an extruder can't match the intent that the user + selects, or just when creating a new printer. + """ return empty_intent_container @pyqtProperty(str, notify = intentCategoryChanged) @@ -137,9 +148,10 @@ class IntentManager(QObject): return "" return active_extruder_stack.intent.getMetaDataEntry("intent_category", "") - ## Apply intent on the stacks. @pyqtSlot(str, str) def selectIntent(self, intent_category: str, quality_type: str) -> None: + """Apply intent on the stacks.""" + Logger.log("i", "Attempting to set intent_category to [%s] and quality type to [%s]", intent_category, quality_type) old_intent_category = self.currentIntentCategory application = cura.CuraApplication.CuraApplication.getInstance() diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 25152b3d5b..1a2ab72a33 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import time @@ -22,6 +22,7 @@ from UM.Settings.SettingFunction import SettingFunction from UM.Signal import postponeSignals, CompressTechnique import cura.CuraApplication # Imported like this to prevent circular references. +from UM.Util import parseBool from cura.Machines.ContainerNode import ContainerNode from cura.Machines.ContainerTree import ContainerTree @@ -37,6 +38,7 @@ from cura.Settings.ExtruderStack import ExtruderStack from cura.Settings.cura_empty_instance_containers import (empty_definition_changes_container, empty_variant_container, empty_material_container, empty_quality_container, empty_quality_changes_container, empty_intent_container) +from cura.UltimakerCloud.UltimakerCloudConstants import META_UM_LINKED_TO_ACCOUNT from .CuraStackBuilder import CuraStackBuilder @@ -61,6 +63,7 @@ class MachineManager(QObject): self._current_root_material_id = {} # type: Dict[str, str] self._default_extruder_position = "0" # to be updated when extruders are switched on and off + self._num_user_settings = 0 self._instance_container_timer = QTimer() # type: QTimer self._instance_container_timer.setInterval(250) @@ -124,6 +127,9 @@ class MachineManager(QObject): self.activeQualityGroupChanged.connect(self.activeQualityDisplayNameChanged) self.activeQualityChangesGroupChanged.connect(self.activeQualityDisplayNameChanged) + self.activeStackValueChanged.connect(self._reCalculateNumUserSettings) + self.numberExtrudersEnabledChanged.connect(self.correctPrintSequence) + activeQualityDisplayNameChanged = pyqtSignal() activeQualityGroupChanged = pyqtSignal() @@ -149,6 +155,22 @@ class MachineManager(QObject): printerConnectedStatusChanged = pyqtSignal() # Emitted every time the active machine change or the outputdevices change rootMaterialChanged = pyqtSignal() + numUserSettingsChanged = pyqtSignal() + + def _reCalculateNumUserSettings(self): + if not self._global_container_stack: + if self._num_user_settings != 0: + self.numUserSettingsChanged.emit() + self._num_user_settings = 0 + return + num_user_settings = self._global_container_stack.getTop().getNumInstances() + stacks = self._global_container_stack.extruderList + for stack in stacks: + num_user_settings += stack.getTop().getNumInstances() + + if self._num_user_settings != num_user_settings: + self._num_user_settings = num_user_settings + self.numUserSettingsChanged.emit() def setInitialActiveMachine(self) -> None: active_machine_id = self._application.getPreferences().getValue("cura/active_machine") @@ -215,8 +237,9 @@ class MachineManager(QObject): return set() return general_definition_containers[0].getAllKeys() - ## Triggered when the global container stack is changed in CuraApplication. def _onGlobalContainerChanged(self) -> None: + """Triggered when the global container stack is changed in CuraApplication.""" + if self._global_container_stack: try: self._global_container_stack.containersChanged.disconnect(self._onContainersChanged) @@ -287,9 +310,15 @@ class MachineManager(QObject): self.activeStackValueChanged.emit() @pyqtSlot(str) - def setActiveMachine(self, stack_id: str) -> None: + def setActiveMachine(self, stack_id: Optional[str]) -> None: self.blurSettings.emit() # Ensure no-one has focus. + if not stack_id: + self._application.setGlobalContainerStack(None) + self.globalContainerChanged.emit() + self._application.showAddPrintersUncancellableDialog.emit() + return + container_registry = CuraContainerRegistry.getInstance() containers = container_registry.findContainerStacks(id = stack_id) if not containers: @@ -320,14 +349,40 @@ class MachineManager(QObject): # This signal might not have been emitted yet (if it didn't change) but we still want the models to update that depend on it because we changed the contents of the containers too. extruder_manager.activeExtruderChanged.emit() - self.__emitChangedSignals() + self._validateVariantsAndMaterials(global_stack) + + def _validateVariantsAndMaterials(self, global_stack) -> None: + # Validate if the machine has the correct variants and materials. + # It can happen that a variant or material is empty, even though the machine has them. This will ensure that + # that situation will be fixed (and not occur again, since it switches it out to the preferred variant or + # variant instead!) + machine_node = ContainerTree.getInstance().machines[global_stack.definition.getId()] + if not self._global_container_stack: + return + for extruder in self._global_container_stack.extruderList: + variant_name = extruder.variant.getName() + variant_node = machine_node.variants.get(variant_name) + if variant_node is None: + Logger.log("w", "An extruder has an unknown variant, switching it to the preferred variant") + self.setVariantByName(extruder.getMetaDataEntry("position"), machine_node.preferred_variant_name) + variant_node = machine_node.variants.get(machine_node.preferred_variant_name) + + material_node = variant_node.materials.get(extruder.material.getMetaDataEntry("base_file")) + if material_node is None: + Logger.log("w", "An extruder has an unknown material, switching it to the preferred material") + if not self.setMaterialById(extruder.getMetaDataEntry("position"), machine_node.preferred_material): + Logger.log("w", "Failed to switch to %s keeping old material instead", machine_node.preferred_material) + - ## Given a definition id, return the machine with this id. - # Optional: add a list of keys and values to filter the list of machines with the given definition id - # \param definition_id \type{str} definition id that needs to look for - # \param metadata_filter \type{dict} list of metadata keys and values used for filtering @staticmethod def getMachine(definition_id: str, metadata_filter: Optional[Dict[str, str]] = None) -> Optional["GlobalStack"]: + """Given a definition id, return the machine with this id. + + Optional: add a list of keys and values to filter the list of machines with the given definition id + :param definition_id: :type{str} definition id that needs to look for + :param metadata_filter: :type{dict} list of metadata keys and values used for filtering + """ + if metadata_filter is None: metadata_filter = {} machines = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine", **metadata_filter) @@ -336,9 +391,9 @@ class MachineManager(QObject): return cast(GlobalStack, machine) return None - @pyqtSlot(str) - @pyqtSlot(str, str) - def addMachine(self, definition_id: str, name: Optional[str] = None) -> None: + @pyqtSlot(str, result=bool) + @pyqtSlot(str, str, result = bool) + def addMachine(self, definition_id: str, name: Optional[str] = None) -> bool: Logger.log("i", "Trying to add a machine with the definition id [%s]", definition_id) if name is None: definitions = CuraContainerRegistry.getInstance().findDefinitionContainers(id = definition_id) @@ -353,6 +408,8 @@ class MachineManager(QObject): self.setActiveMachine(new_stack.getId()) else: Logger.log("w", "Failed creating a new machine!") + return False + return True def _checkStacksHaveErrors(self) -> bool: time_start = time.time() @@ -379,35 +436,20 @@ class MachineManager(QObject): Logger.log("d", "Checking %s stacks for errors took %.2f s" % (count, time.time() - time_start)) return False - ## Check if the global_container has instances in the user container - @pyqtProperty(bool, notify = activeStackValueChanged) + @pyqtProperty(bool, notify = numUserSettingsChanged) def hasUserSettings(self) -> bool: - if not self._global_container_stack: - return False - - if self._global_container_stack.getTop().getNumInstances() != 0: - return True + return self._num_user_settings != 0 - for stack in self._global_container_stack.extruderList: - if stack.getTop().getNumInstances() != 0: - return True - - return False - - @pyqtProperty(int, notify = activeStackValueChanged) + @pyqtProperty(int, notify = numUserSettingsChanged) def numUserSettings(self) -> int: - if not self._global_container_stack: - return 0 - num_user_settings = self._global_container_stack.getTop().getNumInstances() - stacks = self._global_container_stack.extruderList - for stack in stacks: - num_user_settings += stack.getTop().getNumInstances() - return num_user_settings + return self._num_user_settings - ## Delete a user setting from the global stack and all extruder stacks. - # \param key \type{str} the name of the key to delete @pyqtSlot(str) def clearUserSettingAllCurrentStacks(self, key: str) -> None: + """Delete a user setting from the global stack and all extruder stacks. + + :param key: :type{str} the name of the key to delete + """ Logger.log("i", "Clearing the setting [%s] from all stacks", key) if not self._global_container_stack: return @@ -436,11 +478,13 @@ class MachineManager(QObject): for container in send_emits_containers: container.sendPostponedEmits() - ## Check if none of the stacks contain error states - # Note that the _stacks_have_errors is cached due to performance issues - # Calling _checkStack(s)ForErrors on every change is simply too expensive @pyqtProperty(bool, notify = stacksValidationChanged) def stacksHaveErrors(self) -> bool: + """Check if none of the stacks contain error states + + Note that the _stacks_have_errors is cached due to performance issues + Calling _checkStack(s)ForErrors on every change is simply too expensive + """ return bool(self._stacks_have_errors) @pyqtProperty(str, notify = globalContainerChanged) @@ -461,7 +505,15 @@ class MachineManager(QObject): @pyqtProperty(bool, notify = printerConnectedStatusChanged) def activeMachineIsGroup(self) -> bool: - return bool(self._printer_output_devices) and len(self._printer_output_devices[0].printers) > 1 + if self.activeMachine is None: + return False + + group_size = int(self.activeMachine.getMetaDataEntry("group_size", "-1")) + return group_size > 1 + + @pyqtProperty(bool, notify = printerConnectedStatusChanged) + def activeMachineIsLinkedToCurrentAccount(self) -> bool: + return parseBool(self.activeMachine.getMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, "True")) @pyqtProperty(bool, notify = printerConnectedStatusChanged) def activeMachineHasNetworkConnection(self) -> bool: @@ -472,7 +524,11 @@ class MachineManager(QObject): def activeMachineHasCloudConnection(self) -> bool: # A cloud connection is only available if any output device actually is a cloud connected device. return any(d.connectionType == ConnectionType.CloudConnection for d in self._printer_output_devices) - + + @pyqtProperty(bool, notify = printerConnectedStatusChanged) + def activeMachineHasCloudRegistration(self) -> bool: + return self.activeMachine is not None and ConnectionType.CloudConnection in self.activeMachine.configuredConnectionTypes + @pyqtProperty(bool, notify = printerConnectedStatusChanged) def activeMachineIsUsingCloudConnection(self) -> bool: return self.activeMachineHasCloudConnection and not self.activeMachineHasNetworkConnection @@ -510,14 +566,16 @@ class MachineManager(QObject): return material.getId() return "" - ## Gets the layer height of the currently active quality profile. - # - # This is indicated together with the name of the active quality profile. - # - # \return The layer height of the currently active quality profile. If - # there is no quality profile, this returns the default layer height. @pyqtProperty(float, notify = activeQualityGroupChanged) def activeQualityLayerHeight(self) -> float: + """Gets the layer height of the currently active quality profile. + + This is indicated together with the name of the active quality profile. + + :return: The layer height of the currently active quality profile. If + there is no quality profile, this returns the default layer height. + """ + if not self._global_container_stack: return 0 value = self._global_container_stack.getRawProperty("layer_height", "value", skip_until_container = self._global_container_stack.qualityChanges.getId()) @@ -535,14 +593,14 @@ class MachineManager(QObject): @pyqtProperty(str, notify = activeQualityGroupChanged) def activeQualityType(self) -> str: - global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + global_stack = self._application.getGlobalContainerStack() if not global_stack: return "" return global_stack.quality.getMetaDataEntry("quality_type") @pyqtProperty(bool, notify = activeQualityGroupChanged) def isActiveQualitySupported(self) -> bool: - global_container_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + global_container_stack = self._application.getGlobalContainerStack() if not global_container_stack: return False active_quality_group = self.activeQualityGroup() @@ -550,9 +608,10 @@ class MachineManager(QObject): return False return active_quality_group.is_available + @pyqtProperty(bool, notify = activeQualityGroupChanged) def isActiveQualityExperimental(self) -> bool: - global_container_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + global_container_stack = self._application.getGlobalContainerStack() if not global_container_stack: return False active_quality_group = self.activeQualityGroup() @@ -562,7 +621,7 @@ class MachineManager(QObject): @pyqtProperty(str, notify = activeIntentChanged) def activeIntentCategory(self) -> str: - global_container_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + global_container_stack = self._application.getGlobalContainerStack() if not global_container_stack: return "" @@ -571,7 +630,7 @@ class MachineManager(QObject): # Provies a list of extruder positions that have a different intent from the active one. @pyqtProperty("QStringList", notify=activeIntentChanged) def extruderPositionsWithNonActiveIntent(self): - global_container_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + global_container_stack = self._application.getGlobalContainerStack() if not global_container_stack: return [] @@ -587,13 +646,15 @@ class MachineManager(QObject): return result - ## Returns whether there is anything unsupported in the current set-up. - # - # The current set-up signifies the global stack and all extruder stacks, - # so this indicates whether there is any container in any of the container - # stacks that is not marked as supported. @pyqtProperty(bool, notify = activeQualityChanged) def isCurrentSetupSupported(self) -> bool: + """Returns whether there is anything unsupported in the current set-up. + + The current set-up signifies the global stack and all extruder stacks, + so this indicates whether there is any container in any of the container + stacks that is not marked as supported. + """ + if not self._global_container_stack: return False for stack in [self._global_container_stack] + self._global_container_stack.extruderList: @@ -604,9 +665,10 @@ class MachineManager(QObject): return False return True - ## Copy the value of the setting of the current extruder to all other extruders as well as the global container. @pyqtSlot(str) def copyValueToExtruders(self, key: str) -> None: + """Copy the value of the setting of the current extruder to all other extruders as well as the global container.""" + if self._active_container_stack is None or self._global_container_stack is None: return new_value = self._active_container_stack.getProperty(key, "value") @@ -616,9 +678,10 @@ class MachineManager(QObject): if extruder_stack != self._active_container_stack and extruder_stack.getProperty(key, "value") != new_value: extruder_stack.userChanges.setProperty(key, "value", new_value) # TODO: nested property access, should be improved - ## Copy the value of all manually changed settings of the current extruder to all other extruders. @pyqtSlot() def copyAllValuesToExtruders(self) -> None: + """Copy the value of all manually changed settings of the current extruder to all other extruders.""" + if self._active_container_stack is None or self._global_container_stack is None: return @@ -630,19 +693,23 @@ class MachineManager(QObject): # Check if the value has to be replaced extruder_stack.userChanges.setProperty(key, "value", new_value) - ## Get the Definition ID to use to select quality profiles for the currently active machine - # \returns DefinitionID (string) if found, empty string otherwise @pyqtProperty(str, notify = globalContainerChanged) def activeQualityDefinitionId(self) -> str: - global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + """Get the Definition ID to use to select quality profiles for the currently active machine + + :returns: DefinitionID (string) if found, empty string otherwise + """ + global_stack = self._application.getGlobalContainerStack() if not global_stack: return "" return ContainerTree.getInstance().machines[global_stack.definition.getId()].quality_definition - ## Gets how the active definition calls variants - # Caveat: per-definition-variant-title is currently not translated (though the fallback is) @pyqtProperty(str, notify = globalContainerChanged) def activeDefinitionVariantsName(self) -> str: + """Gets how the active definition calls variants + + Caveat: per-definition-variant-title is currently not translated (though the fallback is) + """ fallback_title = catalog.i18nc("@label", "Nozzle") if self._global_container_stack: return self._global_container_stack.definition.getMetaDataEntry("variants_name", fallback_title) @@ -670,26 +737,35 @@ class MachineManager(QObject): other_machine_stacks = [s for s in machine_stacks if s["id"] != machine_id] if other_machine_stacks: self.setActiveMachine(other_machine_stacks[0]["id"]) + else: + self.setActiveMachine(None) - metadata = CuraContainerRegistry.getInstance().findContainerStacksMetadata(id = machine_id)[0] + metadatas = CuraContainerRegistry.getInstance().findContainerStacksMetadata(id = machine_id) + if not metadatas: + return # machine_id doesn't exist. Nothing to remove. + metadata = metadatas[0] ExtruderManager.getInstance().removeMachineExtruders(machine_id) containers = CuraContainerRegistry.getInstance().findInstanceContainersMetadata(type = "user", machine = machine_id) for container in containers: CuraContainerRegistry.getInstance().removeContainer(container["id"]) + machine_stacks = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine", name = machine_id) + if machine_stacks: + CuraContainerRegistry.getInstance().removeContainer(machine_stacks[0].definitionChanges.getId()) CuraContainerRegistry.getInstance().removeContainer(machine_id) # If the printer that is being removed is a network printer, the hidden printers have to be also removed group_id = metadata.get("group_id", None) if group_id: - metadata_filter = {"group_id": group_id} + metadata_filter = {"group_id": group_id, "hidden": True} hidden_containers = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine", **metadata_filter) if hidden_containers: # This reuses the method and remove all printers recursively self.removeMachine(hidden_containers[0].getId()) - ## The selected buildplate is compatible if it is compatible with all the materials in all the extruders @pyqtProperty(bool, notify = activeMaterialChanged) def variantBuildplateCompatible(self) -> bool: + """The selected buildplate is compatible if it is compatible with all the materials in all the extruders""" + if not self._global_container_stack: return True @@ -706,10 +782,12 @@ class MachineManager(QObject): return buildplate_compatible - ## The selected buildplate is usable if it is usable for all materials OR it is compatible for one but not compatible - # for the other material but the buildplate is still usable @pyqtProperty(bool, notify = activeMaterialChanged) def variantBuildplateUsable(self) -> bool: + """The selected buildplate is usable if it is usable for all materials OR it is compatible for one but not compatible + + for the other material but the buildplate is still usable + """ if not self._global_container_stack: return True @@ -730,11 +808,13 @@ class MachineManager(QObject): return result - ## Get the Definition ID of a machine (specified by ID) - # \param machine_id string machine id to get the definition ID of - # \returns DefinitionID if found, None otherwise @pyqtSlot(str, result = str) def getDefinitionByMachineId(self, machine_id: str) -> Optional[str]: + """Get the Definition ID of a machine (specified by ID) + + :param machine_id: string machine id to get the definition ID of + :returns: DefinitionID if found, None otherwise + """ containers = CuraContainerRegistry.getInstance().findContainerStacks(id = machine_id) if containers: return containers[0].definition.getId() @@ -747,11 +827,6 @@ class MachineManager(QObject): result = [] # type: List[str] for setting_instance in container.findInstances(): setting_key = setting_instance.definition.key - if setting_key == "print_sequence": - old_value = container.getProperty(setting_key, "value") - Logger.log("d", "Reset setting [%s] in [%s] because its old value [%s] is no longer valid", setting_key, container, old_value) - result.append(setting_key) - continue if not self._global_container_stack.getProperty(setting_key, "type") in ("extruder", "optional_extruder"): continue @@ -765,8 +840,9 @@ class MachineManager(QObject): Logger.log("d", "Reset setting [%s] in [%s] because its old value [%s] is no longer valid", setting_key, container, old_value) return result - ## Update extruder number to a valid value when the number of extruders are changed, or when an extruder is changed def correctExtruderSettings(self) -> None: + """Update extruder number to a valid value when the number of extruders are changed, or when an extruder is changed""" + if self._global_container_stack is None: return for setting_key in self.getIncompatibleSettingsOnEnabledExtruders(self._global_container_stack.userChanges): @@ -782,9 +858,46 @@ class MachineManager(QObject): title = catalog.i18nc("@info:title", "Settings updated")) caution_message.show() - ## Set the amount of extruders on the active machine (global stack) - # \param extruder_count int the number of extruders to set + def correctPrintSequence(self) -> None: + """ + Sets the Print Sequence setting to "all-at-once" when there are more than one enabled extruders. + + This setting has to be explicitly changed whenever we have more than one enabled extruders to make sure that the + Cura UI is properly updated to reset all the UI elements changes that occur due to the one-at-a-time mode (such + as the reduced build volume, the different convex hulls of the objects etc.). + """ + + setting_key = "print_sequence" + new_value = "all_at_once" + + if self._global_container_stack is None \ + or self._global_container_stack.getProperty(setting_key, "value") == new_value \ + or self.numberExtrudersEnabled < 2: + return + + user_changes_container = self._global_container_stack.userChanges + quality_changes_container = self._global_container_stack.qualityChanges + print_sequence_quality_changes = quality_changes_container.getProperty(setting_key, "value") + print_sequence_user_changes = user_changes_container.getProperty(setting_key, "value") + + # If the user changes container has a value and its the incorrect value, then reset the setting in the user + # changes (so that the circular revert-changes arrow will now show up in the interface) + if print_sequence_user_changes and print_sequence_user_changes != new_value: + user_changes_container.removeInstance(setting_key) + Logger.log("d", "Resetting '{}' in container '{}' because there are more than 1 enabled extruders.".format(setting_key, user_changes_container)) + # If the print sequence doesn't exist in either the user changes or the quality changes (yet it still has the + # wrong value in the global stack), or it exists in the quality changes and it has the wrong value, then set it + # in the user changes + elif (not print_sequence_quality_changes and not print_sequence_user_changes) \ + or (print_sequence_quality_changes and print_sequence_quality_changes != new_value): + user_changes_container.setProperty(setting_key, "value", new_value) + Logger.log("d", "Setting '{}' in '{}' to '{}' because there are more than 1 enabled extruders.".format(setting_key, user_changes_container, new_value)) + def setActiveMachineExtruderCount(self, extruder_count: int) -> None: + """Set the amount of extruders on the active machine (global stack) + + :param extruder_count: int the number of extruders to set + """ if self._global_container_stack is None: return extruder_manager = self._application.getExtruderManager() @@ -881,20 +994,20 @@ class MachineManager(QObject): def defaultExtruderPosition(self) -> str: return self._default_extruder_position - ## This will fire the propertiesChanged for all settings so they will be updated in the front-end @pyqtSlot() def forceUpdateAllSettings(self) -> None: + """This will fire the propertiesChanged for all settings so they will be updated in the front-end""" + if self._global_container_stack is None: return - with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue): - property_names = ["value", "resolve", "validationState"] - for container in [self._global_container_stack] + self._global_container_stack.extruderList: - for setting_key in container.getAllKeys(): - container.propertiesChanged.emit(setting_key, property_names) + property_names = ["value", "resolve", "validationState"] + for container in [self._global_container_stack] + self._global_container_stack.extruderList: + for setting_key in container.getAllKeys(): + container.propertiesChanged.emit(setting_key, property_names) @pyqtSlot(int, bool) def setExtruderEnabled(self, position: int, enabled: bool) -> None: - if self._global_container_stack is None or str(position) not in self._global_container_stack.extruders: + if self._global_container_stack is None or position >= len(self._global_container_stack.extruderList): Logger.log("w", "Could not find extruder on position %s.", position) return extruder = self._global_container_stack.extruderList[position] @@ -904,10 +1017,6 @@ class MachineManager(QObject): self.updateNumberExtrudersEnabled() self.correctExtruderSettings() - # In case this extruder is being disabled and it's the currently selected one, switch to the default extruder - if not enabled and position == ExtruderManager.getInstance().activeExtruderIndex: - ExtruderManager.getInstance().setActiveExtruderIndex(int(self._default_extruder_position)) - # Ensure that the quality profile is compatible with current combination, or choose a compatible one if available self._updateQualityWithMaterial() self.extruderChanged.emit() @@ -915,20 +1024,26 @@ class MachineManager(QObject): self.activeQualityGroupChanged.emit() # Update items in SettingExtruder ExtruderManager.getInstance().extrudersChanged.emit(self._global_container_stack.getId()) - # Make sure the front end reflects changes - self.forceUpdateAllSettings() + # Also trigger the build plate compatibility to update self.activeMaterialChanged.emit() self.activeIntentChanged.emit() + # Force an update of resolve values + property_names = ["resolve", "validationState"] + for setting_key in self._global_container_stack.getAllKeys(): + self._global_container_stack.propertiesChanged.emit(setting_key, property_names) + def _onMaterialNameChanged(self) -> None: self.activeMaterialChanged.emit() - ## Get the signals that signal that the containers changed for all stacks. - # - # This includes the global stack and all extruder stacks. So if any - # container changed anywhere. def _getContainerChangedSignals(self) -> List[Signal]: + """Get the signals that signal that the containers changed for all stacks. + + This includes the global stack and all extruder stacks. So if any + container changed anywhere. + """ + if self._global_container_stack is None: return [] return [s.containersChanged for s in self._global_container_stack.extruderList + [self._global_container_stack]] @@ -941,18 +1056,21 @@ class MachineManager(QObject): container = extruder.userChanges container.setProperty(setting_name, property_name, property_value) - ## Reset all setting properties of a setting for all extruders. - # \param setting_name The ID of the setting to reset. @pyqtSlot(str) def resetSettingForAllExtruders(self, setting_name: str) -> None: + """Reset all setting properties of a setting for all extruders. + + :param setting_name: The ID of the setting to reset. + """ if self._global_container_stack is None: return for extruder in self._global_container_stack.extruderList: container = extruder.userChanges container.removeInstance(setting_name) - ## Update _current_root_material_id when the current root material was changed. def _onRootMaterialChanged(self) -> None: + """Update _current_root_material_id when the current root material was changed.""" + self._current_root_material_id = {} changed = False @@ -1035,7 +1153,7 @@ class MachineManager(QObject): if quality_group is None: self._fixQualityChangesGroupToNotSupported(quality_changes_group) - container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() + container_registry = self._application.getContainerRegistry() quality_changes_container = empty_quality_changes_container quality_container = empty_quality_container # type: InstanceContainer if quality_changes_group.metadata_for_global: @@ -1052,14 +1170,14 @@ class MachineManager(QObject): self._global_container_stack.quality = quality_container self._global_container_stack.qualityChanges = quality_changes_container - for position, extruder in self._global_container_stack.extruders.items(): + for position, extruder in enumerate(self._global_container_stack.extruderList): quality_node = None if quality_group is not None: - quality_node = quality_group.nodes_for_extruders.get(int(position)) + quality_node = quality_group.nodes_for_extruders.get(position) quality_changes_container = empty_quality_changes_container quality_container = empty_quality_container - quality_changes_metadata = quality_changes_group.metadata_per_extruder.get(int(position)) + quality_changes_metadata = quality_changes_group.metadata_per_extruder.get(position) if quality_changes_metadata: containers = container_registry.findContainers(id = quality_changes_metadata["id"]) if containers: @@ -1071,6 +1189,7 @@ class MachineManager(QObject): extruder.qualityChanges = quality_changes_container self.setIntentByCategory(quality_changes_group.intent_category) + self._reCalculateNumUserSettings() self.activeQualityGroupChanged.emit() self.activeQualityChangesGroupChanged.emit() @@ -1078,7 +1197,7 @@ class MachineManager(QObject): def _setVariantNode(self, position: str, variant_node: "VariantNode") -> None: if self._global_container_stack is None: return - self._global_container_stack.extruders[position].variant = variant_node.container + self._global_container_stack.extruderList[int(position)].variant = variant_node.container self.activeVariantChanged.emit() def _setGlobalVariant(self, container_node: "ContainerNode") -> None: @@ -1093,7 +1212,7 @@ class MachineManager(QObject): return if material_node and material_node.container: material_container = material_node.container - self._global_container_stack.extruders[position].material = material_container + self._global_container_stack.extruderList[int(position)].material = material_container root_material_id = material_container.getMetaDataEntry("base_file", None) else: self._global_container_stack.extruderList[int(position)].material = empty_material_container @@ -1114,9 +1233,10 @@ class MachineManager(QObject): return False return True - ## Update current quality type and machine after setting material def _updateQualityWithMaterial(self, *args: Any) -> None: - global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + """Update current quality type and machine after setting material""" + + global_stack = self._application.getGlobalContainerStack() if global_stack is None: return Logger.log("d", "Updating quality/quality_changes due to material change") @@ -1132,9 +1252,8 @@ class MachineManager(QObject): return if not available_quality_types: - if global_stack.qualityChanges == empty_quality_changes_container: - Logger.log("i", "No available quality types found, setting all qualities to empty (Not Supported).") - self._setEmptyQuality() + Logger.log("i", "No available quality types found, setting all qualities to empty (Not Supported).") + self._setEmptyQuality() return if current_quality_type in available_quality_types: @@ -1156,9 +1275,10 @@ class MachineManager(QObject): current_quality_type, quality_type) self._setQualityGroup(candidate_quality_groups[quality_type], empty_quality_changes = True) - ## Update the current intent after the quality changed def _updateIntentWithQuality(self): - global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + """Update the current intent after the quality changed""" + + global_stack = self._application.getGlobalContainerStack() if global_stack is None: return Logger.log("d", "Updating intent due to quality change") @@ -1184,16 +1304,18 @@ class MachineManager(QObject): category = current_category self.setIntentByCategory(category) - ## Update the material profile in the current stacks when the variant is - # changed. - # \param position The extruder stack to update. If provided with None, all - # extruder stacks will be updated. @pyqtSlot() def updateMaterialWithVariant(self, position: Optional[str] = None) -> None: + """Update the material profile in the current stacks when the variant is + + changed. + :param position: The extruder stack to update. If provided with None, all + extruder stacks will be updated. + """ if self._global_container_stack is None: return if position is None: - position_list = list(self._global_container_stack.extruders.keys()) + position_list = [str(position) for position in range(len(self._global_container_stack.extruderList))] else: position_list = [position] @@ -1224,16 +1346,22 @@ class MachineManager(QObject): material_node = nozzle_node.preferredMaterial(approximate_material_diameter) self._setMaterial(position_item, material_node) - ## Given a printer definition name, select the right machine instance. In case it doesn't exist, create a new - # instance with the same network key. @pyqtSlot(str) def switchPrinterType(self, machine_name: str) -> None: + """Given a printer definition name, select the right machine instance. In case it doesn't exist, create a new + + instance with the same network key. + """ # Don't switch if the user tries to change to the same type of printer if self._global_container_stack is None or self._global_container_stack.definition.name == machine_name: return Logger.log("i", "Attempting to switch the printer type to [%s]", machine_name) # Get the definition id corresponding to this machine name - machine_definition_id = CuraContainerRegistry.getInstance().findDefinitionContainers(name = machine_name)[0].getId() + definitions = CuraContainerRegistry.getInstance().findDefinitionContainers(name=machine_name) + if not definitions: + Logger.log("e", "Unable to switch printer type since it could not be found!") + return + machine_definition_id = definitions[0].getId() # Try to find a machine with the same network key metadata_filter = {"group_id": self._global_container_stack.getMetaDataEntry("group_id")} new_machine = self.getMachine(machine_definition_id, metadata_filter = metadata_filter) @@ -1243,7 +1371,7 @@ class MachineManager(QObject): if not new_machine: Logger.log("e", "Failed to create new machine when switching configuration.") return - + for metadata_key in self._global_container_stack.getMetaData(): if metadata_key in new_machine.getMetaData(): continue # Don't copy the already preset stuff. @@ -1261,17 +1389,15 @@ class MachineManager(QObject): # Keep a temporary copy of the global and per-extruder user changes and transfer them to the user changes # of the new machine after the new_machine becomes active. global_user_changes = self._global_container_stack.userChanges - per_extruder_user_changes = {} - for extruder_name, extruder_stack in self._global_container_stack.extruders.items(): - per_extruder_user_changes[extruder_name] = extruder_stack.userChanges + per_extruder_user_changes = [extruder_stack.userChanges for extruder_stack in self._global_container_stack.extruderList] self.setActiveMachine(new_machine.getId()) # Apply the global and per-extruder userChanges to the new_machine (which is of different type than the # previous one). self._global_container_stack.setUserChanges(global_user_changes) - for extruder_name in self._global_container_stack.extruders.keys(): - self._global_container_stack.extruders[extruder_name].setUserChanges(per_extruder_user_changes[extruder_name]) + for i, user_changes in enumerate(per_extruder_user_changes): + self._global_container_stack.extruderList[i].setUserChanges(per_extruder_user_changes[i]) @pyqtSlot(QObject) def applyRemoteConfiguration(self, configuration: PrinterConfigurationModel) -> None: @@ -1282,7 +1408,6 @@ class MachineManager(QObject): with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue): self.switchPrinterType(configuration.printerType) - disabled_used_extruder_position_set = set() extruders_to_disable = set() # If an extruder that's currently used to print a model gets disabled due to the syncing, we need to show @@ -1291,8 +1416,8 @@ class MachineManager(QObject): for extruder_configuration in configuration.extruderConfigurations: # We support "" or None, since the cloud uses None instead of empty strings - extruder_has_hotend = extruder_configuration.hotendID and extruder_configuration.hotendID != "" - extruder_has_material = extruder_configuration.material.guid and extruder_configuration.material.guid != "" + extruder_has_hotend = extruder_configuration.hotendID not in ["", None] + extruder_has_material = extruder_configuration.material.guid not in [None, "", "00000000-0000-0000-0000-000000000000"] # If the machine doesn't have a hotend or material, disable this extruder if not extruder_has_hotend or not extruder_has_material: @@ -1304,13 +1429,15 @@ class MachineManager(QObject): for extruder_configuration in configuration.extruderConfigurations: position = str(extruder_configuration.position) + if int(position) >= len(self._global_container_stack.extruderList): + Logger.warning("Received a configuration for extruder {position}, which is out of bounds for this printer.".format(position=position)) + continue # Remote printer gave more extruders than what Cura had locally, e.g. because the user switched to a single-extruder printer while the sync was being processed. # If the machine doesn't have a hotend or material, disable this extruder if int(position) in extruders_to_disable: self._global_container_stack.extruderList[int(position)].setEnabled(False) need_to_show_message = True - disabled_used_extruder_position_set.add(int(position)) else: machine_node = ContainerTree.getInstance().machines.get(self._global_container_stack.definition.getId()) @@ -1329,7 +1456,7 @@ class MachineManager(QObject): material_container_node = variant_node.materials.get(base_file, material_container_node) self._setMaterial(position, material_container_node) - self._global_container_stack.extruders[position].setEnabled(True) + self._global_container_stack.extruderList[int(position)].setEnabled(True) self.updateMaterialWithVariant(position) self.updateDefaultExtruder() @@ -1341,7 +1468,7 @@ class MachineManager(QObject): # Show human-readable extruder names such as "Extruder Left", "Extruder Front" instead of "Extruder 1, 2, 3". extruder_names = [] - for extruder_position in sorted(disabled_used_extruder_position_set): + for extruder_position in sorted(extruders_to_disable): extruder_stack = self._global_container_stack.extruderList[int(extruder_position)] extruder_name = extruder_stack.definition.getName() extruder_names.append(extruder_name) @@ -1363,24 +1490,30 @@ class MachineManager(QObject): self.updateMaterialWithVariant(None) # Update all materials self._updateQualityWithMaterial() - @pyqtSlot(str, str) - def setMaterialById(self, position: str, root_material_id: str) -> None: + @pyqtSlot(str, str, result = bool) + def setMaterialById(self, position: str, root_material_id: str) -> bool: if self._global_container_stack is None: - return + return False machine_definition_id = self._global_container_stack.definition.id position = str(position) extruder_stack = self._global_container_stack.extruderList[int(position)] nozzle_name = extruder_stack.variant.getName() - material_node = ContainerTree.getInstance().machines[machine_definition_id].variants[nozzle_name].materials[root_material_id] - self.setMaterial(position, material_node) - ## Global_stack: if you want to provide your own global_stack instead of the current active one - # if you update an active machine, special measures have to be taken. + materials = ContainerTree.getInstance().machines[machine_definition_id].variants[nozzle_name].materials + if root_material_id in materials: + self.setMaterial(position, materials[root_material_id]) + return True + return False + @pyqtSlot(str, "QVariant") def setMaterial(self, position: str, container_node, global_stack: Optional["GlobalStack"] = None) -> None: + """Global_stack: if you want to provide your own global_stack instead of the current active one + + if you update an active machine, special measures have to be taken. + """ if global_stack is not None and global_stack != self._global_container_stack: - global_stack.extruders[position].material = container_node.container + global_stack.extruderList[int(position)].material = container_node.container return position = str(position) self.blurSettings.emit() @@ -1399,6 +1532,9 @@ class MachineManager(QObject): machine_definition_id = self._global_container_stack.definition.id machine_node = ContainerTree.getInstance().machines.get(machine_definition_id) variant_node = machine_node.variants.get(variant_name) + if variant_node is None: + Logger.error("There is no variant with the name {variant_name}.") + return self.setVariant(position, variant_node) @pyqtSlot(str, "QVariant") @@ -1421,10 +1557,12 @@ class MachineManager(QObject): # Get all the quality groups for this global stack and filter out by quality_type self.setQualityGroup(ContainerTree.getInstance().getCurrentQualityGroups()[quality_type]) - ## Optionally provide global_stack if you want to use your own - # The active global_stack is treated differently. @pyqtSlot(QObject) def setQualityGroup(self, quality_group: "QualityGroup", no_dialog: bool = False, global_stack: Optional["GlobalStack"] = None) -> None: + """Optionally provide global_stack if you want to use your own + + The active global_stack is treated differently. + """ if global_stack is not None and global_stack != self._global_container_stack: if quality_group is None: Logger.log("e", "Could not set quality group because quality group is None") @@ -1460,7 +1598,7 @@ class MachineManager(QObject): # - "my_profile - Engineering - Fine" (based on an intent) @pyqtProperty("QVariantMap", notify = activeQualityDisplayNameChanged) def activeQualityDisplayNameMap(self) -> Dict[str, str]: - global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + global_stack = self._application.getGlobalContainerStack() if global_stack is None: return {"main": "", "suffix": ""} @@ -1486,16 +1624,18 @@ class MachineManager(QObject): return {"main": main_part, "suffix": suffix_part} - ## Change the intent category of the current printer. - # - # All extruders can change their profiles. If an intent profile is - # available with the desired intent category, that one will get chosen. - # Otherwise the intent profile will be left to the empty profile, which - # represents the "default" intent category. - # \param intent_category The intent category to change to. @pyqtSlot(str) def setIntentByCategory(self, intent_category: str) -> None: - global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + """Change the intent category of the current printer. + + All extruders can change their profiles. If an intent profile is + available with the desired intent category, that one will get chosen. + Otherwise the intent profile will be left to the empty profile, which + represents the "default" intent category. + :param intent_category: The intent category to change to. + """ + + global_stack = self._application.getGlobalContainerStack() if global_stack is None: return container_tree = ContainerTree.getInstance() @@ -1507,7 +1647,17 @@ class MachineManager(QObject): if quality_id == empty_quality_container.getId(): extruder.intent = empty_intent_container continue - quality_node = container_tree.machines[definition_id].variants[variant_name].materials[material_base_file].qualities[quality_id] + + # Yes, we can find this in a single line of code. This makes it easier to read and it has the benefit + # that it doesn't lump key errors together for the crashlogs + try: + machine_node = container_tree.machines[definition_id] + variant_node = machine_node.variants[variant_name] + material_node = variant_node.materials[material_base_file] + quality_node = material_node.qualities[quality_id] + except KeyError as e: + Logger.error("Can't set the intent category '{category}' since the profile '{profile}' in the stack is not supported according to the container tree.".format(category = intent_category, profile = e)) + continue for intent_node in quality_node.intents.values(): if intent_node.intent_category == intent_category: # Found an intent with the correct category. @@ -1516,21 +1666,25 @@ class MachineManager(QObject): else: # No intent had the correct category. extruder.intent = empty_intent_container - ## Get the currently activated quality group. - # - # If no printer is added yet or the printer doesn't have quality profiles, - # this returns ``None``. - # \return The currently active quality group. def activeQualityGroup(self) -> Optional["QualityGroup"]: - global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + """Get the currently activated quality group. + + If no printer is added yet or the printer doesn't have quality profiles, + this returns ``None``. + :return: The currently active quality group. + """ + + global_stack = self._application.getGlobalContainerStack() if not global_stack or global_stack.quality == empty_quality_container: return None return ContainerTree.getInstance().getCurrentQualityGroups().get(self.activeQualityType) - ## Get the name of the active quality group. - # \return The name of the active quality group. @pyqtProperty(str, notify = activeQualityGroupChanged) def activeQualityGroupName(self) -> str: + """Get the name of the active quality group. + + :return: The name of the active quality group. + """ quality_group = self.activeQualityGroup() if quality_group is None: return "" @@ -1552,12 +1706,12 @@ class MachineManager(QObject): return with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue): self._setQualityGroup(self.activeQualityGroup()) - for stack in [self._global_container_stack] + list(self._global_container_stack.extruders.values()): + for stack in [self._global_container_stack] + self._global_container_stack.extruderList: stack.userChanges.clear() @pyqtProperty(QObject, fset = setQualityChangesGroup, notify = activeQualityChangesGroupChanged) def activeQualityChangesGroup(self) -> Optional["QualityChangesGroup"]: - global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + global_stack = self._application.getGlobalContainerStack() if global_stack is None or global_stack.qualityChanges == empty_quality_changes_container: return None @@ -1572,12 +1726,12 @@ class MachineManager(QObject): @pyqtProperty(bool, notify = activeQualityChangesGroupChanged) def hasCustomQuality(self) -> bool: - global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + global_stack = self._application.getGlobalContainerStack() return global_stack is None or global_stack.qualityChanges != empty_quality_changes_container @pyqtProperty(str, notify = activeQualityGroupChanged) def activeQualityOrQualityChangesName(self) -> str: - global_container_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + global_container_stack = self._application.getGlobalContainerStack() if not global_container_stack: return empty_quality_container.getName() if global_container_stack.qualityChanges != empty_quality_changes_container: @@ -1586,26 +1740,27 @@ class MachineManager(QObject): @pyqtProperty(bool, notify = activeQualityGroupChanged) def hasNotSupportedQuality(self) -> bool: - global_container_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + global_container_stack = self._application.getGlobalContainerStack() return (not global_container_stack is None) and global_container_stack.quality == empty_quality_container and global_container_stack.qualityChanges == empty_quality_changes_container @pyqtProperty(bool, notify = activeQualityGroupChanged) def isActiveQualityCustom(self) -> bool: - global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() + global_stack = self._application.getGlobalContainerStack() if global_stack is None: return False return global_stack.qualityChanges != empty_quality_changes_container - def _updateUponMaterialMetadataChange(self) -> None: + def updateUponMaterialMetadataChange(self) -> None: if self._global_container_stack is None: return with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue): self.updateMaterialWithVariant(None) self._updateQualityWithMaterial() - ## This function will translate any printer type name to an abbreviated printer type name @pyqtSlot(str, result = str) def getAbbreviatedMachineName(self, machine_type_name: str) -> str: + """This function will translate any printer type name to an abbreviated printer type name""" + abbr_machine = "" for word in re.findall(r"[\w']+", machine_type_name): if word.lower() == "ultimaker": diff --git a/cura/Settings/MachineNameValidator.py b/cura/Settings/MachineNameValidator.py index acdda4b0a0..c3ca4ed369 100644 --- a/cura/Settings/MachineNameValidator.py +++ b/cura/Settings/MachineNameValidator.py @@ -10,10 +10,13 @@ from UM.Resources import Resources from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.InstanceContainer import InstanceContainer -## Are machine names valid? -# -# Performs checks based on the length of the name. + class MachineNameValidator(QObject): + """Are machine names valid? + + Performs checks based on the length of the name. + """ + def __init__(self, parent = None): super().__init__(parent) @@ -28,16 +31,17 @@ class MachineNameValidator(QObject): # special character, and that up to [machine_name_max_length / 12] times. maximum_special_characters = int(machine_name_max_length / 12) unescaped = r"[a-zA-Z0-9_\-\.\/]" - self.machine_name_regex = r"^((" + unescaped + "){0,12}|.){0," + str(maximum_special_characters) + r"}$" + self.machine_name_regex = r"^[^\.]((" + unescaped + "){0,12}|.){0," + str(maximum_special_characters) + r"}$" validationChanged = pyqtSignal() - ## Check if a specified machine name is allowed. - # - # \param name The machine name to check. - # \return ``QValidator.Invalid`` if it's disallowed, or - # ``QValidator.Acceptable`` if it's allowed. def validate(self, name): + """Check if a specified machine name is allowed. + + :param name: The machine name to check. + :return: ``QValidator.Invalid`` if it's disallowed, or ``QValidator.Acceptable`` if it's allowed. + """ + #Check for file name length of the current settings container (which is the longest file we're saving with the name). try: filename_max_length = os.statvfs(Resources.getDataStoragePath()).f_namemax @@ -50,9 +54,10 @@ class MachineNameValidator(QObject): return QValidator.Acceptable #All checks succeeded. - ## Updates the validation state of a machine name text field. @pyqtSlot(str) def updateValidation(self, new_name): + """Updates the validation state of a machine name text field.""" + is_valid = self.validate(new_name) if is_valid == QValidator.Acceptable: self.validation_regex = "^.*$" #Matches anything. diff --git a/cura/Settings/PerObjectContainerStack.py b/cura/Settings/PerObjectContainerStack.py index a4f1f6ed06..fa2b8f5ec9 100644 --- a/cura/Settings/PerObjectContainerStack.py +++ b/cura/Settings/PerObjectContainerStack.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from typing import Any, Optional @@ -45,13 +45,13 @@ class PerObjectContainerStack(CuraContainerStack): if "original_limit_to_extruder" in context.context: limit_to_extruder = context.context["original_limit_to_extruder"] - if limit_to_extruder is not None and limit_to_extruder != "-1" and limit_to_extruder in global_stack.extruders: + if limit_to_extruder is not None and limit_to_extruder != "-1" and int(limit_to_extruder) <= len(global_stack.extruderList): # set the original limit_to_extruder if this is the first stack that has a non-overridden limit_to_extruder if "original_limit_to_extruder" not in context.context: context.context["original_limit_to_extruder"] = limit_to_extruder if super().getProperty(key, "settable_per_extruder", context): - result = global_stack.extruders[str(limit_to_extruder)].getProperty(key, property_name, context) + result = global_stack.extruderList[int(limit_to_extruder)].getProperty(key, property_name, context) if result is not None: context.popContainer() return result diff --git a/cura/Settings/SetObjectExtruderOperation.py b/cura/Settings/SetObjectExtruderOperation.py index 25c1c6b759..63227c58e3 100644 --- a/cura/Settings/SetObjectExtruderOperation.py +++ b/cura/Settings/SetObjectExtruderOperation.py @@ -6,8 +6,10 @@ from UM.Operations.Operation import Operation from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator -## Simple operation to set the extruder a certain object should be printed with. + class SetObjectExtruderOperation(Operation): + """Simple operation to set the extruder a certain object should be printed with.""" + def __init__(self, node: SceneNode, extruder_id: str) -> None: self._node = node self._extruder_id = extruder_id diff --git a/cura/Settings/SettingInheritanceManager.py b/cura/Settings/SettingInheritanceManager.py index 7db579bf3f..6179e76ab7 100644 --- a/cura/Settings/SettingInheritanceManager.py +++ b/cura/Settings/SettingInheritanceManager.py @@ -45,9 +45,10 @@ class SettingInheritanceManager(QObject): settingsWithIntheritanceChanged = pyqtSignal() - ## Get the keys of all children settings with an override. @pyqtSlot(str, result = "QStringList") def getChildrenKeysWithOverride(self, key: str) -> List[str]: + """Get the keys of all children settings with an override.""" + if self._global_container_stack is None: return [] definitions = self._global_container_stack.definition.findDefinitions(key=key) @@ -163,8 +164,9 @@ class SettingInheritanceManager(QObject): def settingsWithInheritanceWarning(self) -> List[str]: return self._settings_with_inheritance_warning - ## Check if a setting has an inheritance function that is overwritten def _settingIsOverwritingInheritance(self, key: str, stack: ContainerStack = None) -> bool: + """Check if a setting has an inheritance function that is overwritten""" + has_setting_function = False if not stack: stack = self._active_container_stack @@ -177,17 +179,19 @@ class SettingInheritanceManager(QObject): containers = [] # type: List[ContainerInterface] - ## Check if the setting has a user state. If not, it is never overwritten. has_user_state = stack.getProperty(key, "state") == InstanceState.User + """Check if the setting has a user state. If not, it is never overwritten.""" + if not has_user_state: return False - ## If a setting is not enabled, don't label it as overwritten (It's never visible anyway). + # If a setting is not enabled, don't label it as overwritten (It's never visible anyway). if not stack.getProperty(key, "enabled"): return False - ## Also check if the top container is not a setting function (this happens if the inheritance is restored). user_container = stack.getTop() + """Also check if the top container is not a setting function (this happens if the inheritance is restored).""" + if user_container and isinstance(user_container.getProperty(key, "value"), SettingFunction): return False diff --git a/cura/Settings/SettingOverrideDecorator.py b/cura/Settings/SettingOverrideDecorator.py index 2fa5234ec3..9d2b248ba7 100644 --- a/cura/Settings/SettingOverrideDecorator.py +++ b/cura/Settings/SettingOverrideDecorator.py @@ -15,24 +15,27 @@ from UM.Application import Application from cura.Settings.PerObjectContainerStack import PerObjectContainerStack from cura.Settings.ExtruderManager import ExtruderManager -## A decorator that adds a container stack to a Node. This stack should be queried for all settings regarding -# the linked node. The Stack in question will refer to the global stack (so that settings that are not defined by -# this stack still resolve. @signalemitter class SettingOverrideDecorator(SceneNodeDecorator): - ## Event indicating that the user selected a different extruder. + """A decorator that adds a container stack to a Node. This stack should be queried for all settings regarding + + the linked node. The Stack in question will refer to the global stack (so that settings that are not defined by + this stack still resolve. + """ activeExtruderChanged = Signal() + """Event indicating that the user selected a different extruder.""" - ## Non-printing meshes - # - # If these settings are True for any mesh, the mesh does not need a convex hull, - # and is sent to the slicer regardless of whether it fits inside the build volume. - # Note that Support Mesh is not in here because it actually generates - # g-code in the volume of the mesh. _non_printing_mesh_settings = {"anti_overhang_mesh", "infill_mesh", "cutting_mesh"} + """Non-printing meshes + + If these settings are True for any mesh, the mesh does not need a convex hull, + and is sent to the slicer regardless of whether it fits inside the build volume. + Note that Support Mesh is not in here because it actually generates + g-code in the volume of the mesh. + """ _non_thumbnail_visible_settings = {"anti_overhang_mesh", "infill_mesh", "cutting_mesh", "support_mesh"} - def __init__(self): + def __init__(self, *, force_update = True): super().__init__() self._stack = PerObjectContainerStack(container_id = "per_object_stack_" + str(id(self))) self._stack.setDirty(False) # This stack does not need to be saved. @@ -43,6 +46,10 @@ class SettingOverrideDecorator(SceneNodeDecorator): self._is_non_printing_mesh = False self._is_non_thumbnail_visible_mesh = False + self._is_support_mesh = False + self._is_cutting_mesh = False + self._is_infill_mesh = False + self._is_anti_overhang_mesh = False self._stack.propertyChanged.connect(self._onSettingChanged) @@ -50,17 +57,18 @@ class SettingOverrideDecorator(SceneNodeDecorator): Application.getInstance().globalContainerStackChanged.connect(self._updateNextStack) self.activeExtruderChanged.connect(self._updateNextStack) - self._updateNextStack() + if force_update: + self._updateNextStack() def _generateUniqueName(self): return "SettingOverrideInstanceContainer-%s" % uuid.uuid1() def __deepcopy__(self, memo): - ## Create a fresh decorator object - deep_copy = SettingOverrideDecorator() + deep_copy = SettingOverrideDecorator(force_update = False) + """Create a fresh decorator object""" - ## Copy the instance instance_container = copy.deepcopy(self._stack.getContainer(0), memo) + """Copy the instance""" # A unique name must be added, or replaceContainer will not replace it instance_container.setMetaDataEntry("id", self._generateUniqueName()) @@ -71,34 +79,65 @@ class SettingOverrideDecorator(SceneNodeDecorator): # Properly set the right extruder on the copy deep_copy.setActiveExtruder(self._extruder_stack) - # use value from the stack because there can be a delay in signal triggering and "_is_non_printing_mesh" - # has not been updated yet. - deep_copy._is_non_printing_mesh = self._evaluateIsNonPrintingMesh() - deep_copy._is_non_thumbnail_visible_mesh = self._evaluateIsNonThumbnailVisibleMesh() - return deep_copy - ## Gets the currently active extruder to print this object with. - # - # \return An extruder's container stack. def getActiveExtruder(self): + """Gets the currently active extruder to print this object with. + + :return: An extruder's container stack. + """ + return self._extruder_stack - ## Gets the signal that emits if the active extruder changed. - # - # This can then be accessed via a decorator. def getActiveExtruderChangedSignal(self): + """Gets the signal that emits if the active extruder changed. + + This can then be accessed via a decorator. + """ + return self.activeExtruderChanged - ## Gets the currently active extruders position - # - # \return An extruder's position, or None if no position info is available. def getActiveExtruderPosition(self): + """Gets the currently active extruders position + + :return: An extruder's position, or None if no position info is available. + """ + + # for support_meshes, always use the support_extruder + if self._is_support_mesh: + global_container_stack = Application.getInstance().getGlobalContainerStack() + if global_container_stack: + return str(global_container_stack.getProperty("support_extruder_nr", "value")) + containers = ContainerRegistry.getInstance().findContainers(id = self.getActiveExtruder()) if containers: container_stack = containers[0] return container_stack.getMetaDataEntry("position", default=None) + def isCuttingMesh(self): + return self._is_cutting_mesh + + def isSupportMesh(self): + return self._is_support_mesh + + def isInfillMesh(self): + return self._is_infill_mesh + + def isAntiOverhangMesh(self): + return self._is_anti_overhang_mesh + + def _evaluateAntiOverhangMesh(self): + return bool(self._stack.userChanges.getProperty("anti_overhang_mesh", "value")) + + def _evaluateIsCuttingMesh(self): + return bool(self._stack.userChanges.getProperty("cutting_mesh", "value")) + + def _evaluateIsSupportMesh(self): + return bool(self._stack.userChanges.getProperty("support_mesh", "value")) + + def _evaluateInfillMesh(self): + return bool(self._stack.userChanges.getProperty("infill_mesh", "value")) + def isNonPrintingMesh(self): return self._is_non_printing_mesh @@ -117,12 +156,24 @@ class SettingOverrideDecorator(SceneNodeDecorator): # Trigger slice/need slicing if the value has changed. self._is_non_printing_mesh = self._evaluateIsNonPrintingMesh() self._is_non_thumbnail_visible_mesh = self._evaluateIsNonThumbnailVisibleMesh() + + if setting_key == "anti_overhang_mesh": + self._is_anti_overhang_mesh = self._evaluateAntiOverhangMesh() + elif setting_key == "support_mesh": + self._is_support_mesh = self._evaluateIsSupportMesh() + elif setting_key == "cutting_mesh": + self._is_cutting_mesh = self._evaluateIsCuttingMesh() + elif setting_key == "infill_mesh": + self._is_infill_mesh = self._evaluateInfillMesh() + Application.getInstance().getBackend().needsSlicing() Application.getInstance().getBackend().tickle() - ## Makes sure that the stack upon which the container stack is placed is - # kept up to date. def _updateNextStack(self): + """Makes sure that the stack upon which the container stack is placed is + + kept up to date. + """ if self._extruder_stack: extruder_stack = ContainerRegistry.getInstance().findContainerStacks(id = self._extruder_stack) if extruder_stack: @@ -141,10 +192,12 @@ class SettingOverrideDecorator(SceneNodeDecorator): else: self._stack.setNextStack(Application.getInstance().getGlobalContainerStack()) - ## Changes the extruder with which to print this node. - # - # \param extruder_stack_id The new extruder stack to print with. def setActiveExtruder(self, extruder_stack_id): + """Changes the extruder with which to print this node. + + :param extruder_stack_id: The new extruder stack to print with. + """ + self._extruder_stack = extruder_stack_id self._updateNextStack() ExtruderManager.getInstance().resetSelectedObjectExtruders() diff --git a/cura/Settings/SimpleModeSettingsManager.py b/cura/Settings/SimpleModeSettingsManager.py index 3923435e63..6650a9b333 100644 --- a/cura/Settings/SimpleModeSettingsManager.py +++ b/cura/Settings/SimpleModeSettingsManager.py @@ -1,8 +1,7 @@ # Copyright (c) 2017 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Set -from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot +from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty from UM.Application import Application diff --git a/cura/Snapshot.py b/cura/Snapshot.py index 353b5ae17c..6f12aa88ba 100644 --- a/cura/Snapshot.py +++ b/cura/Snapshot.py @@ -30,11 +30,17 @@ class Snapshot: return min_x, max_x, min_y, max_y - ## Return a QImage of the scene - # Uses PreviewPass that leaves out some elements - # Aspect ratio assumes a square @staticmethod def snapshot(width = 300, height = 300): + """Return a QImage of the scene + + Uses PreviewPass that leaves out some elements Aspect ratio assumes a square + + :param width: width of the aspect ratio default 300 + :param height: height of the aspect ratio default 300 + :return: None when there is no model on the build plate otherwise it will return an image + """ + scene = Application.getInstance().getController().getScene() active_camera = scene.getActiveCamera() render_width, render_height = active_camera.getWindowSize() diff --git a/cura/UI/AddPrinterPagesModel.py b/cura/UI/AddPrinterPagesModel.py index d40da59b2a..9b35dbcacc 100644 --- a/cura/UI/AddPrinterPagesModel.py +++ b/cura/UI/AddPrinterPagesModel.py @@ -10,21 +10,28 @@ from .WelcomePagesModel import WelcomePagesModel # class AddPrinterPagesModel(WelcomePagesModel): - def initialize(self) -> None: + def initialize(self, cancellable: bool = True) -> None: self._pages.append({"id": "add_network_or_local_printer", "page_url": self._getBuiltinWelcomePagePath("AddNetworkOrLocalPrinterContent.qml"), "next_page_id": "machine_actions", "next_page_button_text": self._catalog.i18nc("@action:button", "Add"), - "previous_page_button_text": self._catalog.i18nc("@action:button", "Cancel"), }) self._pages.append({"id": "add_printer_by_ip", "page_url": self._getBuiltinWelcomePagePath("AddPrinterByIpContent.qml"), "next_page_id": "machine_actions", }) + self._pages.append({"id": "add_cloud_printers", + "page_url": self._getBuiltinWelcomePagePath("AddCloudPrintersView.qml"), + "is_final_page": True, + "next_page_button_text": self._catalog.i18nc("@action:button", "Finish"), + }) self._pages.append({"id": "machine_actions", "page_url": self._getBuiltinWelcomePagePath("FirstStartMachineActionsContent.qml"), "should_show_function": self.shouldShowMachineActions, }) + if cancellable: + self._pages[0]["previous_page_button_text"] = self._catalog.i18nc("@action:button", "Cancel") + self.setItems(self._pages) diff --git a/cura/UI/CuraSplashScreen.py b/cura/UI/CuraSplashScreen.py index 4074020865..d9caa207f4 100644 --- a/cura/UI/CuraSplashScreen.py +++ b/cura/UI/CuraSplashScreen.py @@ -1,4 +1,4 @@ -# Copyright (c) 2017 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Uranium is released under the terms of the LGPLv3 or higher. from PyQt5.QtCore import Qt, QCoreApplication, QTimer @@ -70,20 +70,20 @@ class CuraSplashScreen(QSplashScreen): font = QFont() # Using system-default font here font.setPixelSize(18) painter.setFont(font) - painter.drawText(60, 70 + self._version_y_offset, 330 * self._scale, 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 len(version) > 1: font.setPixelSize(16) painter.setFont(font) painter.setPen(QColor(200, 200, 200, 255)) - painter.drawText(247, 105 + self._version_y_offset, 330 * self._scale, 255 * self._scale, Qt.AlignLeft | Qt.AlignTop, version[1]) + painter.drawText(247, 105 + self._version_y_offset, round(330 * self._scale), round(255 * self._scale), Qt.AlignLeft | Qt.AlignTop, version[1]) painter.setPen(QColor(255, 255, 255, 255)) # Draw the loading image pen = QPen() - pen.setWidth(6 * self._scale) + pen.setWidthF(6 * self._scale) pen.setColor(QColor(32, 166, 219, 255)) painter.setPen(pen) - painter.drawArc(60, 150, 32 * self._scale, 32 * self._scale, self._loading_image_rotation_angle * 16, 300 * 16) + painter.drawArc(60, 150, round(32 * self._scale), round(32 * self._scale), round(self._loading_image_rotation_angle * 16), 300 * 16) # Draw message text if self._current_message: diff --git a/cura/UI/MachineActionManager.py b/cura/UI/MachineActionManager.py index 6efd3217a1..5e31de32c2 100644 --- a/cura/UI/MachineActionManager.py +++ b/cura/UI/MachineActionManager.py @@ -15,13 +15,15 @@ if TYPE_CHECKING: from cura.MachineAction import MachineAction -## Raised when trying to add an unknown machine action as a required action class UnknownMachineActionError(Exception): + """Raised when trying to add an unknown machine action as a required action""" + pass -## Raised when trying to add a machine action that does not have an unique key. class NotUniqueMachineActionError(Exception): + """Raised when trying to add a machine action that does not have an unique key.""" + pass @@ -71,9 +73,11 @@ class MachineActionManager(QObject): self._definition_ids_with_default_actions_added.add(definition_id) Logger.log("i", "Default machine actions added for machine definition [%s]", definition_id) - ## Add a required action to a machine - # Raises an exception when the action is not recognised. def addRequiredAction(self, definition_id: str, action_key: str) -> None: + """Add a required action to a machine + + Raises an exception when the action is not recognised. + """ if action_key in self._machine_actions: if definition_id in self._required_actions: if self._machine_actions[action_key] not in self._required_actions[definition_id]: @@ -83,8 +87,9 @@ class MachineActionManager(QObject): else: raise UnknownMachineActionError("Action %s, which is required for %s is not known." % (action_key, definition_id)) - ## Add a supported action to a machine. def addSupportedAction(self, definition_id: str, action_key: str) -> None: + """Add a supported action to a machine.""" + if action_key in self._machine_actions: if definition_id in self._supported_actions: if self._machine_actions[action_key] not in self._supported_actions[definition_id]: @@ -94,8 +99,9 @@ class MachineActionManager(QObject): else: Logger.log("w", "Unable to add %s to %s, as the action is not recognised", action_key, definition_id) - ## Add an action to the first start list of a machine. def addFirstStartAction(self, definition_id: str, action_key: str) -> None: + """Add an action to the first start list of a machine.""" + if action_key in self._machine_actions: if definition_id in self._first_start_actions: self._first_start_actions[definition_id].append(self._machine_actions[action_key]) @@ -104,57 +110,69 @@ class MachineActionManager(QObject): else: Logger.log("w", "Unable to add %s to %s, as the action is not recognised", action_key, definition_id) - ## Add a (unique) MachineAction - # if the Key of the action is not unique, an exception is raised. def addMachineAction(self, action: "MachineAction") -> None: + """Add a (unique) MachineAction + + if the Key of the action is not unique, an exception is raised. + """ if action.getKey() not in self._machine_actions: self._machine_actions[action.getKey()] = action else: raise NotUniqueMachineActionError("MachineAction with key %s was already added. Actions must have unique keys.", action.getKey()) - ## Get all actions supported by given machine - # \param definition_id The ID of the definition you want the supported actions of - # \returns set of supported actions. @pyqtSlot(str, result = "QVariantList") def getSupportedActions(self, definition_id: str) -> List["MachineAction"]: + """Get all actions supported by given machine + + :param definition_id: The ID of the definition you want the supported actions of + :returns: set of supported actions. + """ if definition_id in self._supported_actions: return list(self._supported_actions[definition_id]) else: return list() - ## Get all actions required by given machine - # \param definition_id The ID of the definition you want the required actions of - # \returns set of required actions. def getRequiredActions(self, definition_id: str) -> List["MachineAction"]: + """Get all actions required by given machine + + :param definition_id: The ID of the definition you want the required actions of + :returns: set of required actions. + """ if definition_id in self._required_actions: return self._required_actions[definition_id] else: return list() - ## Get all actions that need to be performed upon first start of a given machine. - # Note that contrary to required / supported actions a list is returned (as it could be required to run the same - # action multiple times). - # \param definition_id The ID of the definition that you want to get the "on added" actions for. - # \returns List of actions. @pyqtSlot(str, result = "QVariantList") def getFirstStartActions(self, definition_id: str) -> List["MachineAction"]: + """Get all actions that need to be performed upon first start of a given machine. + + Note that contrary to required / supported actions a list is returned (as it could be required to run the same + action multiple times). + :param definition_id: The ID of the definition that you want to get the "on added" actions for. + :returns: List of actions. + """ if definition_id in self._first_start_actions: return self._first_start_actions[definition_id] else: return [] - ## Remove Machine action from manager - # \param action to remove def removeMachineAction(self, action: "MachineAction") -> None: + """Remove Machine action from manager + + :param action: to remove + """ try: del self._machine_actions[action.getKey()] except KeyError: Logger.log("w", "Trying to remove MachineAction (%s) that was already removed", action.getKey()) - ## Get MachineAction by key - # \param key String of key to select - # \return Machine action if found, None otherwise def getMachineAction(self, key: str) -> Optional["MachineAction"]: + """Get MachineAction by key + + :param key: String of key to select + :return: Machine action if found, None otherwise + """ if key in self._machine_actions: return self._machine_actions[key] else: diff --git a/cura/UI/MachineSettingsManager.py b/cura/UI/MachineSettingsManager.py index 671bb0ece0..1d2604c3c9 100644 --- a/cura/UI/MachineSettingsManager.py +++ b/cura/UI/MachineSettingsManager.py @@ -60,7 +60,6 @@ class MachineSettingsManager(QObject): # In other words: only continue for the UM2 (extended), but not for the UM2+ return - extruder_positions = list(global_stack.extruders.keys()) has_materials = global_stack.getProperty("machine_gcode_flavor", "value") != "UltiGCode" material_node = None @@ -73,12 +72,11 @@ class MachineSettingsManager(QObject): global_stack.removeMetaDataEntry("has_materials") # set materials - for position in extruder_positions: + for position, extruder in enumerate(global_stack.extruderList): if has_materials: - extruder = global_stack.extruderList[int(position)] approximate_diameter = extruder.getApproximateMaterialDiameter() variant_node = ContainerTree.getInstance().machines[global_stack.definition.getId()].variants[extruder.variant.getName()] material_node = variant_node.preferredMaterial(approximate_diameter) - machine_manager.setMaterial(position, material_node) + machine_manager.setMaterial(str(position), material_node) self.forceUpdate() diff --git a/cura/UI/ObjectsModel.py b/cura/UI/ObjectsModel.py index 5526b41098..02d4096278 100644 --- a/cura/UI/ObjectsModel.py +++ b/cura/UI/ObjectsModel.py @@ -1,8 +1,8 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from UM.Logger import Logger import re -from typing import Any, Dict, List, Optional, Union +from typing import Dict, List, Optional, Union from PyQt5.QtCore import QTimer, Qt @@ -31,13 +31,17 @@ class _NodeInfo: self.is_group = is_group # type: bool -## Keep track of all objects in the project class ObjectsModel(ListModel): + """Keep track of all objects in the project""" + NameRole = Qt.UserRole + 1 SelectedRole = Qt.UserRole + 2 OutsideAreaRole = Qt.UserRole + 3 BuilplateNumberRole = Qt.UserRole + 4 NodeRole = Qt.UserRole + 5 + PerObjectSettingsCountRole = Qt.UserRole + 6 + MeshTypeRole = Qt.UserRole + 7 + ExtruderNumberRole = Qt.UserRole + 8 def __init__(self, parent = None) -> None: super().__init__(parent) @@ -46,6 +50,9 @@ class ObjectsModel(ListModel): self.addRoleName(self.SelectedRole, "selected") self.addRoleName(self.OutsideAreaRole, "outside_build_area") self.addRoleName(self.BuilplateNumberRole, "buildplate_number") + self.addRoleName(self.ExtruderNumberRole, "extruder_number") + self.addRoleName(self.PerObjectSettingsCountRole, "per_object_settings_count") + self.addRoleName(self.MeshTypeRole, "mesh_type") self.addRoleName(self.NodeRole, "node") Application.getInstance().getController().getScene().sceneChanged.connect(self._updateSceneDelayed) @@ -91,7 +98,8 @@ class ObjectsModel(ListModel): return True - def _renameNodes(self, node_info_dict: Dict[str, _NodeInfo]) -> List[SceneNode]: + @staticmethod + def _renameNodes(node_info_dict: Dict[str, _NodeInfo]) -> List[SceneNode]: # Go through all names and find out the names for all nodes that need to be renamed. all_nodes = [] # type: List[SceneNode] for name, node_info in node_info_dict.items(): @@ -111,9 +119,7 @@ class ObjectsModel(ListModel): else: new_group_name = "{0}#{1}".format(name, current_index) - old_name = node.getName() node.setName(new_group_name) - Logger.log("d", "Node [%s] renamed to [%s]", old_name, new_group_name) all_nodes.append(node) return all_nodes @@ -172,11 +178,54 @@ class ObjectsModel(ListModel): node_build_plate_number = node.callDecoration("getBuildPlateNumber") + node_mesh_type = "" + per_object_settings_count = 0 + + per_object_stack = node.callDecoration("getStack") + if per_object_stack: + per_object_settings_count = per_object_stack.getTop().getNumInstances() + + if node.callDecoration("isAntiOverhangMesh"): + node_mesh_type = "anti_overhang_mesh" + per_object_settings_count -= 1 # do not count this mesh type setting + elif node.callDecoration("isSupportMesh"): + node_mesh_type = "support_mesh" + per_object_settings_count -= 1 # do not count this mesh type setting + elif node.callDecoration("isCuttingMesh"): + node_mesh_type = "cutting_mesh" + per_object_settings_count -= 1 # do not count this mesh type setting + elif node.callDecoration("isInfillMesh"): + node_mesh_type = "infill_mesh" + per_object_settings_count -= 1 # do not count this mesh type setting + + if per_object_settings_count > 0: + if node_mesh_type == "support_mesh": + # support meshes only allow support settings + per_object_settings_count = 0 + for key in per_object_stack.getTop().getAllKeys(): + if per_object_stack.getTop().getInstance(key).definition.isAncestor("support"): + per_object_settings_count += 1 + elif node_mesh_type == "anti_overhang_mesh": + # anti overhang meshes ignore per model settings + per_object_settings_count = 0 + + extruder_position = node.callDecoration("getActiveExtruderPosition") + if extruder_position is None: + extruder_number = -1 + else: + extruder_number = int(extruder_position) + if node_mesh_type == "anti_overhang_mesh" or node.callDecoration("isGroup"): + # for anti overhang meshes and groups the extruder nr is irrelevant + extruder_number = -1 + nodes.append({ "name": node.getName(), "selected": Selection.isSelected(node), "outside_build_area": is_outside_build_area, "buildplate_number": node_build_plate_number, + "extruder_number": extruder_number, + "per_object_settings_count": per_object_settings_count, + "mesh_type": node_mesh_type, "node": node }) diff --git a/cura/UI/PrintInformation.py b/cura/UI/PrintInformation.py index c39314dc02..22710165b3 100644 --- a/cura/UI/PrintInformation.py +++ b/cura/UI/PrintInformation.py @@ -21,11 +21,13 @@ if TYPE_CHECKING: catalog = i18nCatalog("cura") -## A class for processing and the print times per build plate as well as managing the job name -# -# This class also mangles the current machine name and the filename of the first loaded mesh into a job name. -# This job name is requested by the JobSpecs qml file. class PrintInformation(QObject): + """A class for processing and the print times per build plate as well as managing the job name + + This class also mangles the current machine name and the filename of the first loaded mesh into a job name. + This job name is requested by the JobSpecs qml file. + """ + UNTITLED_JOB_NAME = "Untitled" @@ -200,7 +202,11 @@ class PrintInformation(QObject): self._material_costs[build_plate_number] = [] self._material_names[build_plate_number] = [] - material_preference_values = json.loads(self._application.getInstance().getPreferences().getValue("cura/material_settings")) + try: + material_preference_values = json.loads(self._application.getInstance().getPreferences().getValue("cura/material_settings")) + except json.JSONDecodeError: + Logger.warning("Material preference values are corrupt. Will revert to defaults!") + material_preference_values = {} for index, extruder_stack in enumerate(global_stack.extruderList): if index >= len(self._material_amounts): @@ -380,10 +386,12 @@ class PrintInformation(QObject): def baseName(self): return self._base_name - ## Created an acronym-like abbreviated machine name from the currently - # active machine name. - # Called each time the global stack is switched. def _defineAbbreviatedMachineName(self) -> None: + """Created an acronym-like abbreviated machine name from the currently active machine name. + + Called each time the global stack is switched. + """ + global_container_stack = self._application.getGlobalContainerStack() if not global_container_stack: self._abbr_machine = "" @@ -392,8 +400,9 @@ class PrintInformation(QObject): self._abbr_machine = self._application.getMachineManager().getAbbreviatedMachineName(active_machine_type_name) - ## Utility method that strips accents from characters (eg: â -> a) def _stripAccents(self, to_strip: str) -> str: + """Utility method that strips accents from characters (eg: â -> a)""" + return ''.join(char for char in unicodedata.normalize('NFD', to_strip) if unicodedata.category(char) != 'Mn') @pyqtSlot(result = "QVariantMap") @@ -431,6 +440,7 @@ class PrintInformation(QObject): return self._change_timer.start() - ## Listen to scene changes to check if we need to reset the print information def _onSceneChanged(self) -> None: + """Listen to scene changes to check if we need to reset the print information""" + self.setToZeroPrintInformation(self._active_build_plate) diff --git a/cura/UI/TextManager.py b/cura/UI/TextManager.py index 86838a0b48..dbe7940f26 100644 --- a/cura/UI/TextManager.py +++ b/cura/UI/TextManager.py @@ -28,7 +28,11 @@ class TextManager(QObject): def _loadChangeLogText(self) -> str:
# Load change log texts and organize them with a dict
- file_path = Resources.getPath(Resources.Texts, "change_log.txt")
+ try:
+ file_path = Resources.getPath(Resources.Texts, "change_log.txt")
+ except FileNotFoundError:
+ # I have no idea how / when this happens, but we're getting crash reports about it.
+ return ""
change_logs_dict = {} # type: Dict[Version, Dict[str, List[str]]]
with open(file_path, "r", encoding = "utf-8") as f:
open_version = None # type: Optional[Version]
diff --git a/cura/UI/WelcomePagesModel.py b/cura/UI/WelcomePagesModel.py index c16ec3763e..b816833d67 100644 --- a/cura/UI/WelcomePagesModel.py +++ b/cura/UI/WelcomePagesModel.py @@ -119,8 +119,10 @@ class WelcomePagesModel(ListModel): return next_page_index = idx + is_final_page = page_item.get("is_final_page") + # If we have reached the last page, emit allFinished signal and reset. - if next_page_index == len(self._items): + if next_page_index == len(self._items) or is_final_page: self.atEnd() return @@ -243,6 +245,10 @@ class WelcomePagesModel(ListModel): {"id": "data_collections", "page_url": self._getBuiltinWelcomePagePath("DataCollectionsContent.qml"), }, + {"id": "cloud", + "page_url": self._getBuiltinWelcomePagePath("CloudContent.qml"), + "should_show_function": self.shouldShowCloudPage, + }, {"id": "add_network_or_local_printer", "page_url": self._getBuiltinWelcomePagePath("AddNetworkOrLocalPrinterContent.qml"), "next_page_id": "machine_actions", @@ -251,14 +257,15 @@ class WelcomePagesModel(ListModel): "page_url": self._getBuiltinWelcomePagePath("AddPrinterByIpContent.qml"), "next_page_id": "machine_actions", }, + {"id": "add_cloud_printers", + "page_url": self._getBuiltinWelcomePagePath("AddCloudPrintersView.qml"), + "is_final_page": True, # If we end up in this page, the next button will close the dialog + "next_page_button_text": self._catalog.i18nc("@action:button", "Finish"), + }, {"id": "machine_actions", "page_url": self._getBuiltinWelcomePagePath("FirstStartMachineActionsContent.qml"), - "next_page_id": "cloud", "should_show_function": self.shouldShowMachineActions, }, - {"id": "cloud", - "page_url": self._getBuiltinWelcomePagePath("CloudContent.qml"), - }, ] pages_to_show = all_pages_list @@ -287,6 +294,17 @@ class WelcomePagesModel(ListModel): first_start_actions = self._application.getMachineActionManager().getFirstStartActions(definition_id) return len([action for action in first_start_actions if action.needsUserInteraction()]) > 0 + def shouldShowCloudPage(self) -> bool: + """ + The cloud page should be shown only if the user is not logged in + + :return: True if the user is not logged in, False if he/she is + """ + # Import CuraApplication locally or else it fails + from cura.CuraApplication import CuraApplication + api = CuraApplication.getInstance().getCuraAPI() + return not api.account.isLoggedIn + def addPage(self) -> None: pass diff --git a/cura/UltimakerCloudAuthentication.py b/cura/UltimakerCloud/UltimakerCloudConstants.py index c8346e5c4e..0c8ea0c9c7 100644 --- a/cura/UltimakerCloudAuthentication.py +++ b/cura/UltimakerCloud/UltimakerCloudConstants.py @@ -7,6 +7,11 @@ DEFAULT_CLOUD_API_ROOT = "https://api.ultimaker.com" # type: str DEFAULT_CLOUD_API_VERSION = "1" # type: str DEFAULT_CLOUD_ACCOUNT_API_ROOT = "https://account.ultimaker.com" # type: str +DEFAULT_DIGITAL_FACTORY_URL = "https://digitalfactory.ultimaker.com" # type: str + +# Container Metadata keys +META_UM_LINKED_TO_ACCOUNT = "um_linked_to_account" +"""(bool) Whether a cloud printer is linked to an Ultimaker account""" try: from cura.CuraVersion import CuraCloudAPIRoot # type: ignore @@ -28,3 +33,10 @@ try: CuraCloudAccountAPIRoot = DEFAULT_CLOUD_ACCOUNT_API_ROOT except ImportError: CuraCloudAccountAPIRoot = DEFAULT_CLOUD_ACCOUNT_API_ROOT + +try: + from cura.CuraVersion import CuraDigitalFactoryURL # type: ignore + if CuraDigitalFactoryURL == "": + CuraDigitalFactoryURL = DEFAULT_DIGITAL_FACTORY_URL +except ImportError: + CuraDigitalFactoryURL = DEFAULT_DIGITAL_FACTORY_URL diff --git a/cura/UltimakerCloud/UltimakerCloudScope.py b/cura/UltimakerCloud/UltimakerCloudScope.py new file mode 100644 index 0000000000..5477423099 --- /dev/null +++ b/cura/UltimakerCloud/UltimakerCloudScope.py @@ -0,0 +1,30 @@ +from PyQt5.QtNetwork import QNetworkRequest + +from UM.Logger import Logger +from UM.TaskManagement.HttpRequestScope import DefaultUserAgentScope +from cura.API import Account +from cura.CuraApplication import CuraApplication + + +class UltimakerCloudScope(DefaultUserAgentScope): + """ + Add an Authorization header to the request for Ultimaker Cloud Api requests, if available. + Also add the user agent headers (see DefaultUserAgentScope). + """ + + def __init__(self, application: CuraApplication): + super().__init__(application) + api = application.getCuraAPI() + self._account = api.account # type: Account + + def requestHook(self, request: QNetworkRequest): + super().requestHook(request) + token = self._account.accessToken + if not self._account.isLoggedIn or token is None: + Logger.debug("User is not logged in for Cloud API request to {url}".format(url = request.url().toDisplayString())) + return + + header_dict = { + "Authorization": "Bearer {}".format(token) + } + self.addHeaders(request, header_dict) diff --git a/cura/UltimakerCloud/__init__.py b/cura/UltimakerCloud/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/cura/UltimakerCloud/__init__.py diff --git a/cura/Utils/Decorators.py b/cura/Utils/Decorators.py index 9275ee6ce9..b478f172bc 100644 --- a/cura/Utils/Decorators.py +++ b/cura/Utils/Decorators.py @@ -11,13 +11,15 @@ from typing import Callable SEMANTIC_VERSION_REGEX = re.compile(r"^[0-9]+\.[0-9]+(\.[0-9]+)?$") -## Decorator for functions that belong to a set of APIs. For now, this should only be used for officially supported -# APIs, meaning that those APIs should be versioned and maintained. -# -# \param since_version The earliest version since when this API becomes supported. This means that since this version, -# this API function is supposed to behave the same. This parameter is not used. It's just a -# documentation. def api(since_version: str) -> Callable: + """Decorator for functions that belong to a set of APIs. For now, this should only be used for officially supported + + APIs, meaning that those APIs should be versioned and maintained. + + :param since_version: The earliest version since when this API becomes supported. This means that since this version, + this API function is supposed to behave the same. This parameter is not used. It's just a + documentation. + """ # Make sure that APi versions are semantic versions if not SEMANTIC_VERSION_REGEX.fullmatch(since_version): raise ValueError("API since_version [%s] is not a semantic version." % since_version) diff --git a/cura/XRayPass.py b/cura/XRayPass.py new file mode 100644 index 0000000000..965294ba89 --- /dev/null +++ b/cura/XRayPass.py @@ -0,0 +1,40 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import os.path + +from UM.Resources import Resources +from UM.Application import Application +from UM.PluginRegistry import PluginRegistry + +from UM.View.RenderPass import RenderPass +from UM.View.RenderBatch import RenderBatch +from UM.View.GL.OpenGL import OpenGL + +from cura.Scene.CuraSceneNode import CuraSceneNode +from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator + +class XRayPass(RenderPass): + def __init__(self, width, height): + super().__init__("xray", width, height) + + self._shader = None + self._gl = OpenGL.getInstance().getBindingsObject() + self._scene = Application.getInstance().getController().getScene() + + def render(self): + if not self._shader: + self._shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "xray.shader")) + + batch = RenderBatch(self._shader, type = RenderBatch.RenderType.NoType, backface_cull = False, blend_mode = RenderBatch.BlendMode.Additive) + for node in DepthFirstIterator(self._scene.getRoot()): + if isinstance(node, CuraSceneNode) and node.getMeshData() and node.isVisible(): + batch.addItem(node.getWorldTransformation(copy = False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix()) + + self.bind() + + self._gl.glDisable(self._gl.GL_DEPTH_TEST) + batch.render(self._scene.getActiveCamera()) + self._gl.glEnable(self._gl.GL_DEPTH_TEST) + + self.release() |