Merge pull request #2 from Ultimaker/master

Update Fork
This commit is contained in:
oducceu 2020-08-15 23:41:53 +03:00 committed by GitHub
commit f2b3596554
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3118 changed files with 394327 additions and 551370 deletions

13
.github/no-response.yml vendored Normal file
View file

@ -0,0 +1,13 @@
# Configuration for probot-no-response - https://github.com/probot/no-response
# Number of days of inactivity before an Issue is closed for lack of response
daysUntilClose: 14
# Label requiring a response
responseRequiredLabel: 'Status: Needs Info'
# Comment to post when closing an Issue for lack of response. Set to `false` to disable
closeComment: >
This issue has been automatically closed because there has been no response
to our request for more information from the original author. With only the
information that is currently in the issue, we don't have enough information
to take action. Please reach out if you have or find the answers we need so
that we can investigate further.

4
.gitignore vendored
View file

@ -53,6 +53,8 @@ plugins/GodMode
plugins/OctoPrintPlugin plugins/OctoPrintPlugin
plugins/ProfileFlattener plugins/ProfileFlattener
plugins/SettingsGuide plugins/SettingsGuide
plugins/SettingsGuide2
plugins/SVGToolpathReader
plugins/X3GWriter plugins/X3GWriter
#Build stuff #Build stuff
@ -76,3 +78,5 @@ CuraEngine
#Prevents import failures when plugin running tests #Prevents import failures when plugin running tests
plugins/__init__.py plugins/__init__.py
/venv

View file

@ -1,6 +1,8 @@
Cura Cura
==== ====
This is the new, shiny frontend for Cura. Check [daid/LegacyCura](https://github.com/daid/LegacyCura) for the legacy Cura that everyone knows and loves/hates. We re-worked the whole GUI code at Ultimaker, because the old code started to become unmaintainable. Ultimaker Cura is a state-of-the-art slicer application to prepare your 3D models for printing with a 3D printer. With hundreds of settings and hundreds of community-managed print profiles, Ultimaker Cura is sure to lead your next project to a success.
![Screenshot](screenshot.png)
Logging Issues Logging Issues
------------ ------------

View file

@ -11,11 +11,13 @@ import os
import sys import sys
## Finds all JSON files in the given directory recursively and returns a list of those files in absolute paths.
#
# \param work_dir The directory to look for JSON files recursively.
# \return A list of JSON files in absolute paths that are found in the given directory.
def find_json_files(work_dir: str) -> list: def find_json_files(work_dir: str) -> list:
"""Finds all JSON files in the given directory recursively and returns a list of those files in absolute paths.
:param work_dir: The directory to look for JSON files recursively.
:return: A list of JSON files in absolute paths that are found in the given directory.
"""
json_file_list = [] json_file_list = []
for root, dir_names, file_names in os.walk(work_dir): for root, dir_names, file_names in os.walk(work_dir):
for file_name in file_names: for file_name in file_names:
@ -24,12 +26,14 @@ def find_json_files(work_dir: str) -> list:
return json_file_list return json_file_list
## Removes the given entries from the given JSON file. The file will modified in-place.
#
# \param file_path The JSON file to modify.
# \param entries A list of strings as entries to remove.
# \return None
def remove_entries_from_json_file(file_path: str, entries: list) -> None: def remove_entries_from_json_file(file_path: str, entries: list) -> None:
"""Removes the given entries from the given JSON file. The file will modified in-place.
:param file_path: The JSON file to modify.
:param entries: A list of strings as entries to remove.
:return: None
"""
try: try:
with open(file_path, "r", encoding = "utf-8") as f: with open(file_path, "r", encoding = "utf-8") as f:
package_dict = json.load(f, object_hook = collections.OrderedDict) package_dict = json.load(f, object_hook = collections.OrderedDict)

View file

@ -1,15 +1,16 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import 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 UM.Message import Message
from cura import UltimakerCloudAuthentication from UM.i18n import i18nCatalog
from cura.OAuth2.AuthorizationService import AuthorizationService from cura.OAuth2.AuthorizationService import AuthorizationService
from cura.OAuth2.Models import OAuth2Settings from cura.OAuth2.Models import OAuth2Settings
from cura.UltimakerCloud import UltimakerCloudConstants
if TYPE_CHECKING: if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
@ -17,29 +18,61 @@ if TYPE_CHECKING:
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")
## The account API provides a version-proof bridge to use Ultimaker Accounts class SyncState:
# """QML: Cura.AccountSyncState"""
# Usage: SYNCING = 0
# ``from cura.API import CuraAPI SUCCESS = 1
# api = CuraAPI() ERROR = 2
# api.account.login() IDLE = 3
# api.account.logout()
# api.account.userProfile # Who is logged in``
#
class Account(QObject): 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) loginStateChanged = pyqtSignal(bool)
"""Signal emitted when user logged in or out"""
accessTokenChanged = pyqtSignal() 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: def __init__(self, application: "CuraApplication", parent = None) -> None:
super().__init__(parent) super().__init__(parent)
self._application = application self._application = application
self._new_cloud_printers_detected = False
self._error_message = None # type: Optional[Message] self._error_message = None # type: Optional[Message]
self._logged_in = False 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._callback_port = 32118
self._oauth_root = UltimakerCloudAuthentication.CuraCloudAccountAPIRoot self._oauth_root = UltimakerCloudConstants.CuraCloudAccountAPIRoot
self._oauth_settings = OAuth2Settings( self._oauth_settings = OAuth2Settings(
OAUTH_SERVER_URL= self._oauth_root, OAUTH_SERVER_URL= self._oauth_root,
@ -56,6 +89,16 @@ class Account(QObject):
self._authorization_service = AuthorizationService(self._oauth_settings) 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: def initialize(self) -> None:
self._authorization_service.initialize(self._application.getPreferences()) self._authorization_service.initialize(self._application.getPreferences())
self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged) self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged)
@ -63,12 +106,65 @@ class Account(QObject):
self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged) self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged)
self._authorization_service.loadAuthDataFromPreferences() 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): def _onAccessTokenChanged(self):
self.accessTokenChanged.emit() self.accessTokenChanged.emit()
## Returns a boolean indicating whether the given authentication is applied against staging or not.
@property @property
def is_staging(self) -> bool: def is_staging(self) -> bool:
"""Indication whether the given authentication is applied against staging or not."""
return "staging" in self._oauth_root return "staging" in self._oauth_root
@pyqtProperty(bool, notify=loginStateChanged) @pyqtProperty(bool, notify=loginStateChanged)
@ -83,18 +179,60 @@ class Account(QObject):
self._error_message.show() self._error_message.show()
self._logged_in = False self._logged_in = False
self.loginStateChanged.emit(False) self.loginStateChanged.emit(False)
if self._update_timer.isActive():
self._update_timer.stop()
return return
if self._logged_in != logged_in: if self._logged_in != logged_in:
self._logged_in = logged_in self._logged_in = logged_in
self.loginStateChanged.emit(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() @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: if self._logged_in:
if force_logout_before_login:
self.logout()
else:
# Nothing to do, user already logged in. # Nothing to do, user already logged in.
return return
self._authorization_service.startAuthorizationFlow() self._authorization_service.startAuthorizationFlow(force_logout_before_login)
@pyqtProperty(str, notify=loginStateChanged) @pyqtProperty(str, notify=loginStateChanged)
def userName(self): def userName(self):
@ -114,15 +252,44 @@ class Account(QObject):
def accessToken(self) -> Optional[str]: def accessToken(self) -> Optional[str]:
return self._authorization_service.getAccessToken() 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) @pyqtProperty("QVariantMap", notify = loginStateChanged)
def userProfile(self) -> Optional[Dict[str, Optional[str]]]: def userProfile(self) -> Optional[Dict[str, Optional[str]]]:
"""None if no user is logged in otherwise the logged in user as a dict containing containing user_id, username and profile_image_url """
user_profile = self._authorization_service.getUserProfile() user_profile = self._authorization_service.getUserProfile()
if not user_profile: if not user_profile:
return None return None
return user_profile.__dict__ 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() @pyqtSlot()
def logout(self) -> None: def logout(self) -> None:
if not self._logged_in: if not self._logged_in:

View file

@ -8,28 +8,37 @@ if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication 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: 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: def __init__(self, application: "CuraApplication") -> None:
self.manager = BackupsManager(application) 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]]]: 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() 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: 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) return self.manager.restoreBackup(zip_file, meta_data)

View file

@ -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()

View file

@ -7,32 +7,43 @@ if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication 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: 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: def __init__(self, application: "CuraApplication") -> None:
self.application = application 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: 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) 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: 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() return self.application.getSidebarCustomMenuItems()

View file

@ -9,18 +9,22 @@ if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication 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: 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: def __init__(self, application: "CuraApplication") -> None:
# API methods specific to the settings portion of the UI # API methods specific to the settings portion of the UI

View file

@ -5,6 +5,7 @@ from typing import Optional, TYPE_CHECKING
from PyQt5.QtCore import QObject, pyqtProperty from PyQt5.QtCore import QObject, pyqtProperty
from cura.API.Backups import Backups from cura.API.Backups import Backups
from cura.API.ConnectionStatus import ConnectionStatus
from cura.API.Interface import Interface from cura.API.Interface import Interface
from cura.API.Account import Account from cura.API.Account import Account
@ -12,13 +13,14 @@ if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication 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): 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. # For now we use the same API version to be consistent.
__instance = None # type: "CuraAPI" __instance = None # type: "CuraAPI"
@ -39,12 +41,12 @@ class CuraAPI(QObject):
def __init__(self, application: Optional["CuraApplication"] = None) -> None: def __init__(self, application: Optional["CuraApplication"] = None) -> None:
super().__init__(parent = CuraAPI._application) super().__init__(parent = CuraAPI._application)
# Accounts API
self._account = Account(self._application) self._account = Account(self._application)
# Backups API
self._backups = Backups(self._application) self._backups = Backups(self._application)
self._connectionStatus = ConnectionStatus()
# Interface API # Interface API
self._interface = Interface(self._application) self._interface = Interface(self._application)
@ -53,12 +55,22 @@ class CuraAPI(QObject):
@pyqtProperty(QObject, constant = True) @pyqtProperty(QObject, constant = True)
def account(self) -> "Account": def account(self) -> "Account":
"""Accounts API"""
return self._account return self._account
@pyqtProperty(QObject, constant = True)
def connectionStatus(self) -> "ConnectionStatus":
return self._connectionStatus
@property @property
def backups(self) -> "Backups": def backups(self) -> "Backups":
"""Backups API"""
return self._backups return self._backups
@property @property
def interface(self) -> "Interface": def interface(self) -> "Interface":
"""Interface API"""
return self._interface return self._interface

View file

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

View file

@ -1,6 +1,6 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import List, Optional from typing import Optional
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Logger import Logger from UM.Logger import Logger
@ -16,21 +16,23 @@ from collections import namedtuple
import numpy import numpy
import copy import copy
## Return object for bestSpot
LocationSuggestion = namedtuple("LocationSuggestion", ["x", "y", "penalty_points", "priority"]) 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: 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] build_volume = None # type: Optional[BuildVolume]
def __init__(self, x, y, offset_x, offset_y, scale= 0.5): def __init__(self, x, y, offset_x, offset_y, scale = 0.5):
self._scale = scale # convert input coordinates to arrange coordinates self._scale = scale # convert input coordinates to arrange coordinates
world_x, world_y = int(x * self._scale), int(y * self._scale) world_x, world_y = int(x * self._scale), int(y * self._scale)
self._shape = (world_y, world_x) self._shape = (world_y, world_x)
@ -42,14 +44,21 @@ class Arrange:
self._last_priority = 0 self._last_priority = 0
self._is_empty = True 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 @classmethod
def create(cls, scene_root = None, fixed_nodes = None, scale = 0.5, x = 350, y = 250, min_offset = 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 = Arrange(x, y, x // 2, y // 2, scale = scale)
arranger.centerFirst() arranger.centerFirst()
@ -71,8 +80,11 @@ class Arrange:
# After scaling (like up to 0.1 mm) the node might not have points # After scaling (like up to 0.1 mm) the node might not have points
if not points.size: if not points.size:
continue continue
try:
shape_arr = ShapeArray.fromPolygon(points, scale = scale) shape_arr = ShapeArray.fromPolygon(points, scale = scale)
except ValueError:
Logger.logException("w", "Unable to create polygon")
continue
arranger.place(0, 0, shape_arr) arranger.place(0, 0, shape_arr)
# If a build volume was set, add the disallowed areas # If a build volume was set, add the disallowed areas
@ -84,16 +96,21 @@ class Arrange:
arranger.place(0, 0, shape_arr, update_empty = False) arranger.place(0, 0, shape_arr, update_empty = False)
return arranger return arranger
## This resets the optimization for finding location based on size
def resetLastPriority(self): def resetLastPriority(self):
"""This resets the optimization for finding location based on size"""
self._last_priority = 0 self._last_priority = 0
## Find placement for a node (using offset shape) and place it (using hull shape) def findNodePlacement(self, node: SceneNode, offset_shape_arr: ShapeArray, hull_shape_arr: ShapeArray, step = 1) -> bool:
# return the nodes that should be placed """Find placement for a node (using offset shape) and place it (using hull shape)
# \param node
# \param offset_shape_arr ShapeArray with offset, for placing the shape :param node: The node to be placed
# \param hull_shape_arr ShapeArray without offset, used to find location :param offset_shape_arr: shape array with offset, for placing the shape
def findNodePlacement(self, node: SceneNode, offset_shape_arr: ShapeArray, hull_shape_arr: ShapeArray, step = 1): :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( best_spot = self.bestSpot(
hull_shape_arr, start_prio = self._last_priority, step = step) hull_shape_arr, start_prio = self._last_priority, step = step)
x, y = best_spot.x, best_spot.y x, y = best_spot.x, best_spot.y
@ -119,29 +136,32 @@ class Arrange:
node.setPosition(Vector(200, center_y, 100)) node.setPosition(Vector(200, center_y, 100))
return found_spot return found_spot
## Fill priority, center is best. Lower value is better
# This is a strategy for the arranger.
def centerFirst(self): def centerFirst(self):
"""Fill priority, center is best. Lower value is better. """
# Square distance: creates a more round shape # Square distance: creates a more round shape
self._priority = numpy.fromfunction( self._priority = numpy.fromfunction(
lambda j, i: (self._offset_x - i) ** 2 + (self._offset_y - j) ** 2, self._shape, dtype=numpy.int32) 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 = numpy.unique(self._priority)
self._priority_unique_values.sort() self._priority_unique_values.sort()
## Fill priority, back is best. Lower value is better
# This is a strategy for the arranger.
def backFirst(self): def backFirst(self):
"""Fill priority, back is best. Lower value is better """
self._priority = numpy.fromfunction( self._priority = numpy.fromfunction(
lambda j, i: 10 * j + abs(self._offset_x - i), self._shape, dtype=numpy.int32) 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 = numpy.unique(self._priority)
self._priority_unique_values.sort() self._priority_unique_values.sort()
## Return the amount of "penalty points" for polygon, which is the sum of priority def checkShape(self, x, y, shape_arr) -> Optional[numpy.ndarray]:
# None if occupied """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 :param x: x-coordinate to check shape
# \param shape_arr the ShapeArray object to place :param y: y-coordinate to check shape
def checkShape(self, x, y, shape_arr): :param shape_arr: the shape array object to place
:return: None if occupied
"""
x = int(self._scale * x) x = int(self._scale * x)
y = int(self._scale * y) y = int(self._scale * y)
offset_x = x + self._offset_x + shape_arr.offset_x offset_x = x + self._offset_x + shape_arr.offset_x
@ -165,12 +185,15 @@ class Arrange:
offset_x:offset_x + shape_arr.arr.shape[1]] offset_x:offset_x + shape_arr.arr.shape[1]]
return numpy.sum(prio_slice[numpy.where(shape_arr.arr == 1)]) return numpy.sum(prio_slice[numpy.where(shape_arr.arr == 1)])
## Find "best" spot for ShapeArray def bestSpot(self, shape_arr, start_prio = 0, step = 1) -> LocationSuggestion:
# Return namedtuple with properties x, y, penalty_points, priority. """Find "best" spot for ShapeArray
# \param shape_arr ShapeArray
# \param start_prio Start with this priority value (and skip the ones before) :param shape_arr: shape array
# \param step Slicing value, higher = more skips = faster but less accurate :param start_prio: Start with this priority value (and skip the ones before)
def bestSpot(self, shape_arr, start_prio = 0, step = 1): :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) start_idx_list = numpy.where(self._priority_unique_values == start_prio)
if start_idx_list: if start_idx_list:
try: try:
@ -179,6 +202,7 @@ class Arrange:
start_idx = 0 start_idx = 0
else: else:
start_idx = 0 start_idx = 0
priority = 0
for priority in self._priority_unique_values[start_idx::step]: for priority in self._priority_unique_values[start_idx::step]:
tryout_idx = numpy.where(self._priority == priority) tryout_idx = numpy.where(self._priority == priority)
for idx in range(len(tryout_idx[0])): for idx in range(len(tryout_idx[0])):
@ -192,13 +216,17 @@ class Arrange:
return LocationSuggestion(x = projected_x, y = projected_y, penalty_points = penalty_points, priority = priority) 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 :-( 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): 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) x = int(self._scale * x)
y = int(self._scale * y) y = int(self._scale * y)
offset_x = x + self._offset_x + shape_arr.offset_x offset_x = x + self._offset_x + shape_arr.offset_x

View file

@ -18,8 +18,9 @@ from cura.Arranging.ShapeArray import ShapeArray
from typing import List from typing import List
## Do arrangements on multiple build plates (aka builtiplexer)
class ArrangeArray: class ArrangeArray:
"""Do arrangements on multiple build plates (aka builtiplexer)"""
def __init__(self, x: int, y: int, fixed_nodes: List[SceneNode]) -> None: def __init__(self, x: int, y: int, fixed_nodes: List[SceneNode]) -> None:
self._x = x self._x = x
self._y = y self._y = y

View file

@ -1,5 +1,6 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import QCoreApplication
from UM.Application import Application from UM.Application import Application
from UM.Job import Job from UM.Job import Job
@ -94,6 +95,7 @@ class ArrangeObjectsJob(Job):
status_message.setProgress((idx + 1) / len(nodes_arr) * 100) status_message.setProgress((idx + 1) / len(nodes_arr) * 100)
Job.yieldThread() Job.yieldThread()
QCoreApplication.processEvents()
grouped_operation.push() grouped_operation.push()

View file

@ -11,19 +11,24 @@ if TYPE_CHECKING:
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
## Polygon representation as an array for use with Arrange
class ShapeArray: class ShapeArray:
"""Polygon representation as an array for use with :py:class:`cura.Arranging.Arrange.Arrange`"""
def __init__(self, arr: numpy.array, offset_x: float, offset_y: float, scale: float = 1) -> None: def __init__(self, arr: numpy.array, offset_x: float, offset_y: float, scale: float = 1) -> None:
self.arr = arr self.arr = arr
self.offset_x = offset_x self.offset_x = offset_x
self.offset_y = offset_y self.offset_y = offset_y
self.scale = scale self.scale = scale
## Instantiate from a bunch of vertices
# \param vertices
# \param scale scale the coordinates
@classmethod @classmethod
def fromPolygon(cls, vertices: numpy.array, scale: float = 1) -> "ShapeArray": 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 # scale
vertices = vertices * scale vertices = vertices * scale
# flip y, x -> x, y # flip y, x -> x, y
@ -44,12 +49,16 @@ class ShapeArray:
arr[0][0] = 1 arr[0][0] = 1
return cls(arr, offset_x, offset_y) 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 @classmethod
def fromNode(cls, node: "SceneNode", min_offset: float, scale: float = 0.5, include_children: bool = False) -> Tuple[Optional["ShapeArray"], Optional["ShapeArray"]]: 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 = node._transformation
transform_x = transform._data[0][3] transform_x = transform._data[0][3]
transform_y = transform._data[2][3] transform_y = transform._data[2][3]
@ -88,14 +97,19 @@ class ShapeArray:
return offset_shape_arr, hull_shape_arr 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 @classmethod
def arrayFromPolygon(cls, shape: Tuple[int, int], vertices: numpy.array) -> numpy.array: 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 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 fill = numpy.ones(base_array.shape) * True # Initialize boolean array defining shape fill
@ -111,16 +125,21 @@ class ShapeArray:
return base_array 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 @classmethod
def _check(cls, p1: numpy.array, p2: numpy.array, base_array: numpy.array) -> Optional[numpy.array]: 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]: if p1[0] == p2[0] and p1[1] == p2[1]:
return None return None
idxs = numpy.indices(base_array.shape) # Create 3D array of indices idxs = numpy.indices(base_array.shape) # Create 3D array of indices

View file

@ -31,7 +31,6 @@ class AutoSave:
self._change_timer.timeout.connect(self._onTimeout) self._change_timer.timeout.connect(self._onTimeout)
self._application.globalContainerStackChanged.connect(self._onGlobalStackChanged) self._application.globalContainerStackChanged.connect(self._onGlobalStackChanged)
self._onGlobalStackChanged() self._onGlobalStackChanged()
self._triggerTimer()
def _triggerTimer(self, *args: Any) -> None: def _triggerTimer(self, *args: Any) -> None:
if not self._saving: if not self._saving:

View file

@ -18,24 +18,26 @@ if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication 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: class Backup:
# These files should be ignored when making a backup. """The back-up class holds all data about a back-up.
IGNORED_FILES = [r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc"]
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") catalog = i18nCatalog("cura")
"""Re-use translation catalog"""
def __init__(self, application: "CuraApplication", zip_file: bytes = None, meta_data: Dict[str, str] = None) -> None: def __init__(self, application: "CuraApplication", zip_file: bytes = None, meta_data: Dict[str, str] = None) -> None:
self._application = application self._application = application
self.zip_file = zip_file # type: Optional[bytes] self.zip_file = zip_file # type: Optional[bytes]
self.meta_data = meta_data # type: Optional[Dict[str, str]] self.meta_data = meta_data # type: Optional[Dict[str, str]]
## Create a back-up from the current user config folder.
def makeFromCurrent(self) -> None: def makeFromCurrent(self) -> None:
"""Create a back-up from the current user config folder."""
cura_release = self._application.getVersion() cura_release = self._application.getVersion()
version_data_dir = Resources.getDataStoragePath() version_data_dir = Resources.getDataStoragePath()
@ -62,9 +64,9 @@ class Backup:
files = archive.namelist() files = archive.namelist()
# Count the metadata items. We do this in a rather naive way at the moment. # 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 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 = len([s for s in files if "materials/" in s]) - 1 material_count = max(len([s for s in files if "materials/" in s]) - 1, 0)
profile_count = len([s for s in files if "quality_changes/" in s]) - 1 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]) 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. # Store the archive and metadata so the BackupManager can fetch them when needed.
@ -77,10 +79,13 @@ class Backup:
"plugin_count": str(plugin_count) "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]: 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)) ignore_string = re.compile("|".join(self.IGNORED_FILES))
try: try:
archive = ZipFile(buffer, "w", ZIP_DEFLATED) archive = ZipFile(buffer, "w", ZIP_DEFLATED)
@ -99,13 +104,17 @@ class Backup:
"Could not create archive from user data directory: {}".format(error))) "Could not create archive from user data directory: {}".format(error)))
return None return None
## Show a UI message.
def _showMessage(self, message: str) -> None: def _showMessage(self, message: str) -> None:
"""Show a UI message."""
Message(message, title=self.catalog.i18nc("@info:title", "Backup"), lifetime=30).show() Message(message, title=self.catalog.i18nc("@info:title", "Backup"), lifetime=30).show()
## Restore this back-up.
# \return Whether we had success or not.
def restore(self) -> bool: 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): 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. # We can restore without the minimum required information.
Logger.log("w", "Tried to restore a Cura backup without having proper data or meta data.") Logger.log("w", "Tried to restore a Cura backup without having proper data or meta data.")
@ -139,12 +148,14 @@ class Backup:
return extracted 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 @staticmethod
def _extractArchive(archive: "ZipFile", target_path: str) -> bool: 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. # Implement security recommendations: Sanity check on zip files will make it harder to spoof.
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
@ -158,7 +169,7 @@ class Backup:
Logger.log("d", "Extracting backup to location: %s", target_path) Logger.log("d", "Extracting backup to location: %s", target_path)
try: try:
archive.extractall(target_path) archive.extractall(target_path)
except PermissionError: except (PermissionError, EnvironmentError):
Logger.logException("e", "Unable to extract the backup due to permission errors") Logger.logException("e", "Unable to extract the backup due to permission or file system errors.")
return False return False
return True return True

View file

@ -10,18 +10,24 @@ if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication 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: 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: def __init__(self, application: "CuraApplication") -> None:
self._application = application 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]]]: 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() self._disableAutoSave()
backup = Backup(self._application) backup = Backup(self._application)
backup.makeFromCurrent() 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. # 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 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: 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 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. # 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.") 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. # 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) 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: 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() auto_save = self._application.getAutoSave()
# The auto save is only not created if the application has not yet started. # The auto save is only not created if the application has not yet started.
if auto_save: if auto_save:
@ -58,8 +68,10 @@ class BackupsManager:
else: else:
Logger.log("e", "Unable to disable the autosave as application init has not been completed") 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: def _enableAutoSave(self) -> None:
"""Re-enable auto-save and other saving after we're done."""
self._application.enableSave(True)
auto_save = self._application.getAutoSave() auto_save = self._application.getAutoSave()
# The auto save is only not created if the application has not yet started. # The auto save is only not created if the application has not yet started.
if auto_save: if auto_save:

View file

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
import numpy import numpy
@ -44,8 +44,9 @@ catalog = i18nCatalog("cura")
PRIME_CLEARANCE = 6.5 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): class BuildVolume(SceneNode):
"""Build volume is a special kind of node that is responsible for rendering the printable area & disallowed areas."""
raftThicknessChanged = Signal() raftThicknessChanged = Signal()
def __init__(self, application: "CuraApplication", parent: Optional[SceneNode] = None) -> None: def __init__(self, application: "CuraApplication", parent: Optional[SceneNode] = None) -> None:
@ -91,6 +92,8 @@ class BuildVolume(SceneNode):
self._adhesion_type = None # type: Any self._adhesion_type = None # type: Any
self._platform = Platform(self) self._platform = Platform(self)
self._edge_disallowed_size = None
self._build_volume_message = Message(catalog.i18nc("@info:status", self._build_volume_message = Message(catalog.i18nc("@info:status",
"The build volume height has been reduced due to the value of the" "The build volume height has been reduced due to the value of the"
" \"Print Sequence\" setting to prevent the gantry from colliding" " \"Print Sequence\" setting to prevent the gantry from colliding"
@ -105,19 +108,17 @@ class BuildVolume(SceneNode):
self._application.globalContainerStackChanged.connect(self._onStackChanged) self._application.globalContainerStackChanged.connect(self._onStackChanged)
self._onStackChanged()
self._engine_ready = False self._engine_ready = False
self._application.engineCreatedSignal.connect(self._onEngineCreated) self._application.engineCreatedSignal.connect(self._onEngineCreated)
self._has_errors = False self._has_errors = False
self._application.getController().getScene().sceneChanged.connect(self._onSceneChanged) 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_objects = set() # type: Set[SceneNode]
self._scene_change_timer = QTimer() 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.setSingleShot(True)
self._scene_change_timer.timeout.connect(self._onSceneChangeTimerFinished) self._scene_change_timer.timeout.connect(self._onSceneChangeTimerFinished)
@ -163,10 +164,12 @@ class BuildVolume(SceneNode):
self._scene_objects = new_scene_objects self._scene_objects = new_scene_objects
self._onSettingPropertyChanged("print_sequence", "value") # Create fake event, so right settings are triggered. 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): 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") per_mesh_stack = node.callDecoration("getStack")
if per_mesh_stack: if per_mesh_stack:
per_mesh_stack.propertyChanged.connect(self._onSettingPropertyChanged) per_mesh_stack.propertyChanged.connect(self._onSettingPropertyChanged)
@ -187,10 +190,14 @@ class BuildVolume(SceneNode):
if shape: if shape:
self._shape = 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: 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) return math.sqrt(self._width * self._width + self._height * self._height + self._depth * self._depth)
def getDisallowedAreas(self) -> List[Polygon]: def getDisallowedAreas(self) -> List[Polygon]:
@ -226,9 +233,9 @@ class BuildVolume(SceneNode):
return True return True
## For every sliceable node, update node._outside_buildarea
#
def updateNodeBoundaryCheck(self): def updateNodeBoundaryCheck(self):
"""For every sliceable node, update node._outside_buildarea"""
if not self._global_container_stack: if not self._global_container_stack:
return return
@ -274,7 +281,9 @@ class BuildVolume(SceneNode):
if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled: if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled:
node.setOutsideBuildArea(True) node.setOutsideBuildArea(True)
continue 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 continue
node.setOutsideBuildArea(False) node.setOutsideBuildArea(False)
@ -293,8 +302,13 @@ class BuildVolume(SceneNode):
for child_node in children: for child_node in children:
child_node.setOutsideBuildArea(group_node.isOutsideBuildArea()) 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: 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: if not isinstance(node, CuraSceneNode) or self._global_container_stack is None:
return return
@ -482,8 +496,9 @@ class BuildVolume(SceneNode):
self._disallowed_area_size = max(size, self._disallowed_area_size) self._disallowed_area_size = max(size, self._disallowed_area_size)
return mb.build() return mb.build()
## Recalculates the build volume & disallowed areas.
def rebuild(self) -> None: def rebuild(self) -> None:
"""Recalculates the build volume & disallowed areas."""
if not self._width or not self._height or not self._depth: if not self._width or not self._height or not self._depth:
return return
@ -584,8 +599,9 @@ class BuildVolume(SceneNode):
def _onStackChanged(self): def _onStackChanged(self):
self._stack_change_timer.start() self._stack_change_timer.start()
## Update the build volume visualization
def _onStackChangeTimerFinished(self) -> None: def _onStackChangeTimerFinished(self) -> None:
"""Update the build volume visualization"""
if self._global_container_stack: if self._global_container_stack:
self._global_container_stack.propertyChanged.disconnect(self._onSettingPropertyChanged) self._global_container_stack.propertyChanged.disconnect(self._onSettingPropertyChanged)
extruders = ExtruderManager.getInstance().getActiveExtruderStacks() extruders = ExtruderManager.getInstance().getActiveExtruderStacks()
@ -710,15 +726,15 @@ class BuildVolume(SceneNode):
self._depth = self._global_container_stack.getProperty("machine_depth", "value") self._depth = self._global_container_stack.getProperty("machine_depth", "value")
self._shape = self._global_container_stack.getProperty("machine_shape", "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): 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._updateDisallowedAreas()
self._updateRaftThickness() self._updateRaftThickness()
self._extra_z_clearance = self._calculateExtraZClearance(ExtruderManager.getInstance().getUsedExtruderStacks()) self._extra_z_clearance = self._calculateExtraZClearance(ExtruderManager.getInstance().getUsedExtruderStacks())
@ -731,6 +747,7 @@ class BuildVolume(SceneNode):
self._error_areas = [] self._error_areas = []
used_extruders = ExtruderManager.getInstance().getUsedExtruderStacks() used_extruders = ExtruderManager.getInstance().getUsedExtruderStacks()
self._edge_disallowed_size = None # Force a recalculation
disallowed_border_size = self.getEdgeDisallowedSize() disallowed_border_size = self.getEdgeDisallowedSize()
result_areas = self._computeDisallowedAreasStatic(disallowed_border_size, used_extruders) # Normal machine disallowed areas can always be added. result_areas = self._computeDisallowedAreasStatic(disallowed_border_size, used_extruders) # Normal machine disallowed areas can always be added.
@ -764,7 +781,8 @@ class BuildVolume(SceneNode):
if prime_tower_collision: # Already found a collision. if prime_tower_collision: # Already found a collision.
break break
if self._global_container_stack.getProperty("prime_tower_brim_enable", "value") and self._global_container_stack.getProperty("adhesion_type", "value") != "raft": 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")
prime_tower_areas[extruder_id][area_index] = prime_tower_area.getMinkowskiHull(Polygon.approximatedCircle(brim_size))
if not prime_tower_collision: if not prime_tower_collision:
result_areas[extruder_id].extend(prime_tower_areas[extruder_id]) result_areas[extruder_id].extend(prime_tower_areas[extruder_id])
result_areas_no_brim[extruder_id].extend(prime_tower_areas[extruder_id]) result_areas_no_brim[extruder_id].extend(prime_tower_areas[extruder_id])
@ -780,15 +798,14 @@ class BuildVolume(SceneNode):
for extruder_id in result_areas_no_brim: for extruder_id in result_areas_no_brim:
self._disallowed_areas_no_brim.extend(result_areas_no_brim[extruder_id]) 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): 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 = {} result = {}
adhesion_extruder = None #type: ExtruderStack adhesion_extruder = None #type: ExtruderStack
for extruder in used_extruders: for extruder in used_extruders:
@ -826,18 +843,18 @@ class BuildVolume(SceneNode):
return result 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]]: 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]] result = {} # type: Dict[str, List[Polygon]]
if not self._global_container_stack: if not self._global_container_stack:
return result return result
@ -865,19 +882,18 @@ class BuildVolume(SceneNode):
return result 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]]: 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. # Convert disallowed areas to polygons and dilate them.
machine_disallowed_polygons = [] machine_disallowed_polygons = []
if self._global_container_stack is None: if self._global_container_stack is None:
@ -1008,13 +1024,14 @@ class BuildVolume(SceneNode):
return result 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]: 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_values = ExtruderManager.getInstance().getAllExtruderSettings(setting_key, "value")
all_types = ExtruderManager.getInstance().getAllExtruderSettings(setting_key, "type") all_types = ExtruderManager.getInstance().getAllExtruderSettings(setting_key, "type")
for i, (setting_value, setting_type) in enumerate(zip(all_values, all_types)): for i, (setting_value, setting_type) in enumerate(zip(all_values, all_types)):
@ -1022,16 +1039,23 @@ class BuildVolume(SceneNode):
all_values[i] = 0 all_values[i] = 0
return all_values 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: if self._global_container_stack is None:
return None return None
container_stack = self._global_container_stack container_stack = self._global_container_stack
adhesion_type = adhesion_override
if adhesion_type is None:
adhesion_type = container_stack.getProperty("adhesion_type", "value") adhesion_type = container_stack.getProperty("adhesion_type", "value")
skirt_brim_line_width = self._global_container_stack.getProperty("skirt_brim_line_width", "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") 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. # 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") 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 bed_adhesion_size = skirt_brim_line_width * brim_line_count * initial_layer_line_width_factor / 100.0
@ -1040,7 +1064,7 @@ class BuildVolume(SceneNode):
# We don't create an additional line for the extruder we're printing the brim with. # We don't create an additional line for the extruder we're printing the brim with.
bed_adhesion_size -= skirt_brim_line_width * initial_layer_line_width_factor / 100.0 bed_adhesion_size -= skirt_brim_line_width * initial_layer_line_width_factor / 100.0
elif adhesion_type == "skirt": # 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_distance = self._global_container_stack.getProperty("skirt_gap", "value")
skirt_line_count = self._global_container_stack.getProperty("skirt_line_count", "value") skirt_line_count = self._global_container_stack.getProperty("skirt_line_count", "value")
@ -1084,25 +1108,34 @@ class BuildVolume(SceneNode):
def _calculateMoveFromWallRadius(self, used_extruders): def _calculateMoveFromWallRadius(self, used_extruders):
move_from_wall_radius = 0 # Moves that start from outer wall. 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")) for stack in used_extruders:
move_from_wall_radius = max(all_values) if stack.getProperty("travel_avoid_other_parts", "value"):
avoid_enabled_per_extruder = [stack.getProperty("travel_avoid_other_parts", "value") for stack in used_extruders] move_from_wall_radius = max(move_from_wall_radius, stack.getProperty("travel_avoid_distance", "value"))
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). infill_wipe_distance = stack.getProperty("infill_wipe_dist", "value")
if avoid_other_parts_enabled: num_walls = stack.getProperty("wall_line_count", "value")
move_from_wall_radius = max(move_from_wall_radius, avoid_distance) 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 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): 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: if not self._global_container_stack or not self._global_container_stack.extruderList:
return 0 return 0
if self._edge_disallowed_size is not None:
return self._edge_disallowed_size
container_stack = self._global_container_stack container_stack = self._global_container_stack
used_extruders = ExtruderManager.getInstance().getUsedExtruderStacks() used_extruders = ExtruderManager.getInstance().getUsedExtruderStacks()
@ -1118,8 +1151,8 @@ class BuildVolume(SceneNode):
# Now combine our different pieces of data to get the final border size. # 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 the bed adhesion, since the bed adhesion goes around support.
# Support expansion is added to farthest shield distance, since the shields go 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) self._edge_disallowed_size = max(move_from_wall_radius, support_expansion + farthest_shield_distance, support_expansion + bed_adhesion_size)
return border_size return self._edge_disallowed_size
def _clamp(self, value, min_value, max_value): def _clamp(self, value, min_value, max_value):
return max(min(value, max_value), min_value) return max(min(value, max_value), min_value)
@ -1128,10 +1161,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"] _skirt_settings = ["adhesion_type", "skirt_gap", "skirt_line_count", "skirt_brim_line_width", "brim_width", "brim_line_count", "raft_margin", "draft_shield_enabled", "draft_shield_dist", "initial_layer_line_width_factor"]
_raft_settings = ["adhesion_type", "raft_base_thickness", "raft_interface_thickness", "raft_surface_layers", "raft_surface_thickness", "raft_airgap", "layer_0_z_overlap"] _raft_settings = ["adhesion_type", "raft_base_thickness", "raft_interface_thickness", "raft_surface_layers", "raft_surface_thickness", "raft_airgap", "layer_0_z_overlap"]
_extra_z_settings = ["retraction_hop_enabled", "retraction_hop"] _extra_z_settings = ["retraction_hop_enabled", "retraction_hop"]
_prime_settings = ["extruder_prime_pos_x", "extruder_prime_pos_y", "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"] _tower_settings = ["prime_tower_enable", "prime_tower_size", "prime_tower_position_x", "prime_tower_position_y", "prime_tower_brim_enable"]
_ooze_shield_settings = ["ooze_shield_enabled", "ooze_shield_dist"] _ooze_shield_settings = ["ooze_shield_enabled", "ooze_shield_dist"]
_distance_settings = ["infill_wipe_dist", "travel_avoid_distance", "support_offset", "support_enable", "travel_avoid_other_parts", "travel_avoid_supports"] _distance_settings = ["infill_wipe_dist", "travel_avoid_distance", "support_offset", "support_enable", "travel_avoid_other_parts", "travel_avoid_supports", "wall_line_count", "wall_line_width_0", "wall_line_width_x"]
_extruder_settings = ["support_enable", "support_bottom_enable", "support_roof_enable", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "brim_line_count", "adhesion_extruder_nr", "adhesion_type"] #Settings that can affect which extruders are used. _extruder_settings = ["support_enable", "support_bottom_enable", "support_roof_enable", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "brim_line_count", "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"] _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 _disallowed_area_settings = _skirt_settings + _prime_settings + _tower_settings + _ooze_shield_settings + _distance_settings + _extruder_settings

View file

@ -88,7 +88,7 @@ class CrashHandler:
@staticmethod @staticmethod
def pruneSensitiveData(obj: Any) -> Any: def pruneSensitiveData(obj: Any) -> Any:
if isinstance(obj, str): if isinstance(obj, str):
return obj.replace(home_dir, "<user_home>") return obj.replace("\\\\", "\\").replace(home_dir, "<user_home>")
if isinstance(obj, list): if isinstance(obj, list):
return [CrashHandler.pruneSensitiveData(item) for item in obj] return [CrashHandler.pruneSensitiveData(item) for item in obj]
if isinstance(obj, dict): if isinstance(obj, dict):
@ -150,8 +150,9 @@ class CrashHandler:
self._sendCrashReport() self._sendCrashReport()
os._exit(1) os._exit(1)
## Backup the current resource directories and create clean ones.
def _backupAndStartClean(self): def _backupAndStartClean(self):
"""Backup the current resource directories and create clean ones."""
Resources.factoryReset() Resources.factoryReset()
self.early_crash_dialog.close() self.early_crash_dialog.close()
@ -162,8 +163,9 @@ class CrashHandler:
def _showDetailedReport(self): def _showDetailedReport(self):
self.dialog.exec_() self.dialog.exec_()
## Creates a modal dialog.
def _createDialog(self): def _createDialog(self):
"""Creates a modal dialog."""
self.dialog.setMinimumWidth(640) self.dialog.setMinimumWidth(640)
self.dialog.setMinimumHeight(640) self.dialog.setMinimumHeight(640)
self.dialog.setWindowTitle(catalog.i18nc("@title:window", "Crash Report")) self.dialog.setWindowTitle(catalog.i18nc("@title:window", "Crash Report"))
@ -213,6 +215,16 @@ class CrashHandler:
locale.getdefaultlocale()[0] locale.getdefaultlocale()[0]
self.data["locale_cura"] = self.cura_locale 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 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", "Cura language") + ":</b> " + str(self.cura_locale) + "<br/>"
crash_info += "<b>" + catalog.i18nc("@label", "OS language") + ":</b> " + str(self.data["locale_os"]) + "<br/>" crash_info += "<b>" + catalog.i18nc("@label", "OS language") + ":</b> " + str(self.data["locale_os"]) + "<br/>"
@ -236,6 +248,8 @@ class CrashHandler:
scope.set_tag("locale_cura", self.cura_locale) scope.set_tag("locale_cura", self.cura_locale)
scope.set_tag("is_enterprise", ApplicationMetadata.IsEnterpriseVersion) scope.set_tag("is_enterprise", ApplicationMetadata.IsEnterpriseVersion)
scope.set_context("plugins", self.data["plugins"])
scope.set_user({"id": str(uuid.getnode())}) scope.set_user({"id": str(uuid.getnode())})
return group return group

View file

@ -43,9 +43,10 @@ class CuraActions(QObject):
event = CallFunctionEvent(self._openUrl, [QUrl("https://github.com/Ultimaker/Cura/issues")], {}) event = CallFunctionEvent(self._openUrl, [QUrl("https://github.com/Ultimaker/Cura/issues")], {})
cura.CuraApplication.CuraApplication.getInstance().functionEvent(event) cura.CuraApplication.CuraApplication.getInstance().functionEvent(event)
## Reset camera position and direction to default
@pyqtSlot() @pyqtSlot()
def homeCamera(self) -> None: def homeCamera(self) -> None:
"""Reset camera position and direction to default"""
scene = cura.CuraApplication.CuraApplication.getInstance().getController().getScene() scene = cura.CuraApplication.CuraApplication.getInstance().getController().getScene()
camera = scene.getActiveCamera() camera = scene.getActiveCamera()
if camera: if camera:
@ -54,9 +55,10 @@ class CuraActions(QObject):
camera.setPerspective(True) camera.setPerspective(True)
camera.lookAt(Vector(0, 0, 0)) camera.lookAt(Vector(0, 0, 0))
## Center all objects in the selection
@pyqtSlot() @pyqtSlot()
def centerSelection(self) -> None: def centerSelection(self) -> None:
"""Center all objects in the selection"""
operation = GroupedOperation() operation = GroupedOperation()
for node in Selection.getAllSelectedObjects(): for node in Selection.getAllSelectedObjects():
current_node = node current_node = node
@ -73,18 +75,21 @@ class CuraActions(QObject):
operation.addOperation(center_operation) operation.addOperation(center_operation)
operation.push() operation.push()
## Multiply all objects in the selection
#
# \param count The number of times to multiply the selection.
@pyqtSlot(int) @pyqtSlot(int)
def multiplySelection(self, count: int) -> None: 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 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 = MultiplyObjectsJob(Selection.getAllSelectedObjects(), count, min_offset = max(min_offset, 8))
job.start() job.start()
## Delete all selected objects.
@pyqtSlot() @pyqtSlot()
def deleteSelection(self) -> None: def deleteSelection(self) -> None:
"""Delete all selected objects."""
if not cura.CuraApplication.CuraApplication.getInstance().getController().getToolsEnabled(): if not cura.CuraApplication.CuraApplication.getInstance().getController().getToolsEnabled():
return return
@ -106,11 +111,13 @@ class CuraActions(QObject):
op.push() 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) @pyqtSlot(str)
def setExtruderForSelection(self, extruder_id: str) -> None: 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() operation = GroupedOperation()
nodes_to_change = [] nodes_to_change = []

View file

@ -4,82 +4,67 @@
import os import os
import sys import sys
import time 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 import numpy
from PyQt5.QtCore import QObject, QTimer, QUrl, pyqtSignal, pyqtProperty, QEvent, Q_ENUMS from PyQt5.QtCore import QObject, QTimer, QUrl, pyqtSignal, pyqtProperty, QEvent, Q_ENUMS
from PyQt5.QtGui import QColor, QIcon from PyQt5.QtGui import QColor, QIcon
from PyQt5.QtWidgets import QMessageBox
from PyQt5.QtQml import qmlRegisterUncreatableType, qmlRegisterSingletonType, qmlRegisterType 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.Application import Application
from UM.Decorators import override from UM.Decorators import override
from UM.FlameProfiler import pyqtSlot from UM.FlameProfiler import pyqtSlot
from UM.Logger import Logger 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.AxisAlignedBox import AxisAlignedBox
from UM.Math.Matrix import Matrix from UM.Math.Matrix import Matrix
from UM.Math.Quaternion import Quaternion from UM.Math.Quaternion import Quaternion
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
from UM.Mesh.ReadMeshJob import ReadMeshJob from UM.Mesh.ReadMeshJob import ReadMeshJob
from UM.Message import Message
from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
from UM.Operations.GroupedOperation import GroupedOperation from UM.Operations.GroupedOperation import GroupedOperation
from UM.Operations.SetTransformOperation import SetTransformOperation 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.Camera import Camera
from UM.Scene.GroupDecorator import GroupDecorator from UM.Scene.GroupDecorator import GroupDecorator
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Scene.Selection import Selection from UM.Scene.Selection import Selection
from UM.Scene.ToolHandle import ToolHandle from UM.Scene.ToolHandle import ToolHandle
from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.Settings.InstanceContainer import InstanceContainer from UM.Settings.InstanceContainer import InstanceContainer
from UM.Settings.SettingDefinition import SettingDefinition, DefinitionPropertyType from UM.Settings.SettingDefinition import SettingDefinition, DefinitionPropertyType
from UM.Settings.SettingFunction import SettingFunction from UM.Settings.SettingFunction import SettingFunction
from UM.Settings.Validator import Validator from UM.Settings.Validator import Validator
from UM.View.SelectionPass import SelectionPass # For typing.
from UM.Workspace.WorkspaceReader import WorkspaceReader from UM.Workspace.WorkspaceReader import WorkspaceReader
from UM.i18n import i18nCatalog
from cura import ApplicationMetadata
from cura.API import CuraAPI from cura.API import CuraAPI
from cura.API.Account import Account
from cura.Arranging.Arrange import Arrange from cura.Arranging.Arrange import Arrange
from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob
from cura.Arranging.ArrangeObjectsAllBuildPlatesJob import ArrangeObjectsAllBuildPlatesJob from cura.Arranging.ArrangeObjectsAllBuildPlatesJob import ArrangeObjectsAllBuildPlatesJob
from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob
from cura.Arranging.ShapeArray import ShapeArray 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.Machines.MachineErrorChecker import MachineErrorChecker from cura.Machines.MachineErrorChecker import MachineErrorChecker
from cura.Machines.Models.BuildPlateModel import BuildPlateModel from cura.Machines.Models.BuildPlateModel import BuildPlateModel
from cura.Machines.Models.CustomQualityProfilesDropDownMenuModel import CustomQualityProfilesDropDownMenuModel from cura.Machines.Models.CustomQualityProfilesDropDownMenuModel import CustomQualityProfilesDropDownMenuModel
from cura.Machines.Models.DiscoveredPrintersModel import DiscoveredPrintersModel 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.ExtrudersModel import ExtrudersModel
from cura.Machines.Models.FavoriteMaterialsModel import FavoriteMaterialsModel from cura.Machines.Models.FavoriteMaterialsModel import FavoriteMaterialsModel
from cura.Machines.Models.FirstStartMachineActionsModel import FirstStartMachineActionsModel from cura.Machines.Models.FirstStartMachineActionsModel import FirstStartMachineActionsModel
from cura.Machines.Models.GenericMaterialsModel import GenericMaterialsModel from cura.Machines.Models.GenericMaterialsModel import GenericMaterialsModel
from cura.Machines.Models.GlobalStacksModel import GlobalStacksModel 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.MaterialBrandsModel import MaterialBrandsModel
from cura.Machines.Models.MaterialManagementModel import MaterialManagementModel from cura.Machines.Models.MaterialManagementModel import MaterialManagementModel
from cura.Machines.Models.MultiBuildPlateModel import MultiBuildPlateModel from cura.Machines.Models.MultiBuildPlateModel import MultiBuildPlateModel
@ -89,51 +74,47 @@ from cura.Machines.Models.QualityProfilesDropDownMenuModel import QualityProfile
from cura.Machines.Models.QualitySettingsModel import QualitySettingsModel from cura.Machines.Models.QualitySettingsModel import QualitySettingsModel
from cura.Machines.Models.SettingVisibilityPresetsModel import SettingVisibilityPresetsModel from cura.Machines.Models.SettingVisibilityPresetsModel import SettingVisibilityPresetsModel
from cura.Machines.Models.UserChangesModel import UserChangesModel from cura.Machines.Models.UserChangesModel import UserChangesModel
from cura.Machines.Models.IntentModel import IntentModel from cura.Operations.SetParentOperation import SetParentOperation
from cura.Machines.Models.IntentCategoryModel import IntentCategoryModel
from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
from cura.PrinterOutput.NetworkMJPGImage import NetworkMJPGImage from cura.PrinterOutput.NetworkMJPGImage import NetworkMJPGImage
from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
import cura.Settings.cura_empty_instance_containers 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.ContainerManager import ContainerManager
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
from cura.Settings.CuraFormulaFunctions import CuraFormulaFunctions from cura.Settings.CuraFormulaFunctions import CuraFormulaFunctions
from cura.Settings.ExtruderManager import ExtruderManager from cura.Settings.ExtruderManager import ExtruderManager
from cura.Settings.ExtruderStack import ExtruderStack 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.MachineManager import MachineManager
from cura.Settings.MachineNameValidator import MachineNameValidator from cura.Settings.MachineNameValidator import MachineNameValidator
from cura.Settings.IntentManager import IntentManager
from cura.Settings.MaterialSettingsVisibilityHandler import MaterialSettingsVisibilityHandler from cura.Settings.MaterialSettingsVisibilityHandler import MaterialSettingsVisibilityHandler
from cura.Settings.SettingInheritanceManager import SettingInheritanceManager from cura.Settings.SettingInheritanceManager import SettingInheritanceManager
from cura.Settings.SidebarCustomMenuItemsModel import SidebarCustomMenuItemsModel from cura.Settings.SidebarCustomMenuItemsModel import SidebarCustomMenuItemsModel
from cura.Settings.SimpleModeSettingsManager import SimpleModeSettingsManager from cura.Settings.SimpleModeSettingsManager import SimpleModeSettingsManager
from cura.TaskManagement.OnExitCallbackManager import OnExitCallbackManager from cura.TaskManagement.OnExitCallbackManager import OnExitCallbackManager
from cura.UI import CuraSplashScreen, MachineActionManager, PrintInformation from cura.UI import CuraSplashScreen, MachineActionManager, PrintInformation
from cura.UI.AddPrinterPagesModel import AddPrinterPagesModel
from cura.UI.MachineSettingsManager import MachineSettingsManager from cura.UI.MachineSettingsManager import MachineSettingsManager
from cura.UI.ObjectsModel import ObjectsModel 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.RecommendedMode import RecommendedMode
from cura.UI.TextManager import TextManager
from cura.UI.WelcomePagesModel import WelcomePagesModel from cura.UI.WelcomePagesModel import WelcomePagesModel
from cura.UI.WhatsNewPagesModel import WhatsNewPagesModel from cura.UI.WhatsNewPagesModel import WhatsNewPagesModel
from cura.UltimakerCloud import UltimakerCloudConstants
from cura.Utils.NetworkingUtil import NetworkingUtil from cura.Utils.NetworkingUtil import NetworkingUtil
from .SingleInstance import SingleInstance
from .AutoSave import AutoSave
from . import PlatformPhysics
from . import BuildVolume from . import BuildVolume
from . import CameraAnimation from . import CameraAnimation
from . import CuraActions from . import CuraActions
from . import PlatformPhysics
from . import PrintJobPreviewImageProvider from . import PrintJobPreviewImageProvider
from .AutoSave import AutoSave
from cura.TaskManagement.OnExitCallbackManager import OnExitCallbackManager from .SingleInstance import SingleInstance
from cura import ApplicationMetadata, UltimakerCloudAuthentication
from cura.Settings.GlobalStack import GlobalStack
if TYPE_CHECKING: if TYPE_CHECKING:
from UM.Settings.EmptyInstanceContainer import EmptyInstanceContainer from UM.Settings.EmptyInstanceContainer import EmptyInstanceContainer
@ -145,7 +126,7 @@ class CuraApplication(QtApplication):
# SettingVersion represents the set of settings available in the machine/extruder definitions. # SettingVersion represents the set of settings available in the machine/extruder definitions.
# You need to make sure that this version number needs to be increased if there is any non-backwards-compatible # You need to make sure that this version number needs to be increased if there is any non-backwards-compatible
# changes of the settings. # changes of the settings.
SettingVersion = 11 SettingVersion = 15
Created = False Created = False
@ -222,9 +203,11 @@ class CuraApplication(QtApplication):
self._quality_management_model = None self._quality_management_model = None
self._discovered_printer_model = DiscoveredPrintersModel(self, parent = self) 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._first_start_machine_actions_model = None
self._welcome_pages_model = WelcomePagesModel(self, parent = self) self._welcome_pages_model = WelcomePagesModel(self, parent = self)
self._add_printer_pages_model = AddPrinterPagesModel(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._whats_new_pages_model = WhatsNewPagesModel(self, parent = self)
self._text_manager = TextManager(parent = self) self._text_manager = TextManager(parent = self)
@ -263,6 +246,7 @@ class CuraApplication(QtApplication):
# Backups # Backups
self._auto_save = None # type: Optional[AutoSave] self._auto_save = None # type: Optional[AutoSave]
self._enable_save = True
self._container_registry_class = CuraContainerRegistry self._container_registry_class = CuraContainerRegistry
# Redefined here in order to please the typing. # Redefined here in order to please the typing.
@ -272,15 +256,22 @@ class CuraApplication(QtApplication):
@pyqtProperty(str, constant=True) @pyqtProperty(str, constant=True)
def ultimakerCloudApiRootUrl(self) -> str: def ultimakerCloudApiRootUrl(self) -> str:
return UltimakerCloudAuthentication.CuraCloudAPIRoot return UltimakerCloudConstants.CuraCloudAPIRoot
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def ultimakerCloudAccountRootUrl(self) -> str: 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): 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() super().addCommandLineOptions()
self._cli_parser.add_argument("--help", "-h", self._cli_parser.add_argument("--help", "-h",
action = "store_true", action = "store_true",
@ -322,6 +313,9 @@ class CuraApplication(QtApplication):
super().initialize() super().initialize()
self._preferences.addPreference("cura/single_instance", False)
self._use_single_instance = self._preferences.getValue("cura/single_instance")
self.__sendCommandToSingleInstance() self.__sendCommandToSingleInstance()
self._initializeSettingDefinitions() self._initializeSettingDefinitions()
self._initializeSettingFunctions() self._initializeSettingFunctions()
@ -342,8 +336,9 @@ class CuraApplication(QtApplication):
Logger.log("i", "Single instance commands were sent, exiting") Logger.log("i", "Single instance commands were sent, exiting")
sys.exit(0) sys.exit(0)
# Adds expected directory names and search paths for Resources.
def __addExpectedResourceDirsAndSearchPaths(self): 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 # 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"]: for dir_name in ["extruders", "machine_instances", "materials", "plugins", "quality", "quality_changes", "user", "variants", "intent"]:
Resources.addExpectedDirNameInData(dir_name) Resources.addExpectedDirNameInData(dir_name)
@ -385,9 +380,12 @@ class CuraApplication(QtApplication):
SettingDefinition.addSettingType("[int]", None, str, None) 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): 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) self._cura_formula_functions = CuraFormulaFunctions(self)
SettingFunction.registerOperator("extruderValue", self._cura_formula_functions.getValueInExtruder) SettingFunction.registerOperator("extruderValue", self._cura_formula_functions.getValueInExtruder)
@ -397,8 +395,9 @@ class CuraApplication(QtApplication):
SettingFunction.registerOperator("valueFromContainer", self._cura_formula_functions.getValueFromContainerAtIndex) SettingFunction.registerOperator("valueFromContainer", self._cura_formula_functions.getValueFromContainerAtIndex)
SettingFunction.registerOperator("extruderValueFromContainer", self._cura_formula_functions.getValueFromContainerAtIndexInExtruder) SettingFunction.registerOperator("extruderValueFromContainer", self._cura_formula_functions.getValueFromContainerAtIndexInExtruder)
# Adds all resources and container related resources.
def __addAllResourcesAndContainerResources(self) -> None: def __addAllResourcesAndContainerResources(self) -> None:
"""Adds all resources and container related resources."""
Resources.addStorageType(self.ResourceTypes.QualityInstanceContainer, "quality") Resources.addStorageType(self.ResourceTypes.QualityInstanceContainer, "quality")
Resources.addStorageType(self.ResourceTypes.QualityChangesInstanceContainer, "quality_changes") Resources.addStorageType(self.ResourceTypes.QualityChangesInstanceContainer, "quality_changes")
Resources.addStorageType(self.ResourceTypes.VariantInstanceContainer, "variants") Resources.addStorageType(self.ResourceTypes.VariantInstanceContainer, "variants")
@ -423,8 +422,9 @@ class CuraApplication(QtApplication):
Resources.addType(self.ResourceTypes.QmlFiles, "qml") Resources.addType(self.ResourceTypes.QmlFiles, "qml")
Resources.addType(self.ResourceTypes.Firmware, "firmware") Resources.addType(self.ResourceTypes.Firmware, "firmware")
# Adds all empty containers.
def __addAllEmptyContainers(self) -> None: def __addAllEmptyContainers(self) -> None:
"""Adds all empty containers."""
# Add empty variant, material and quality containers. # Add empty variant, material and quality containers.
# Since they are empty, they should never be serialized and instead just programmatically created. # Since they are empty, they should never be serialized and instead just programmatically created.
# We need them to simplify the switching between materials. # We need them to simplify the switching between materials.
@ -449,9 +449,10 @@ class CuraApplication(QtApplication):
self._container_registry.addContainer(cura.Settings.cura_empty_instance_containers.empty_quality_changes_container) 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 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): 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( self._version_upgrade_manager.setCurrentVersions(
{ {
("quality", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityInstanceContainer, "application/x-uranium-instancecontainer"), ("quality", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityInstanceContainer, "application/x-uranium-instancecontainer"),
@ -466,12 +467,16 @@ class CuraApplication(QtApplication):
} }
) )
# Runs preparations that needs to be done before the starting process.
def startSplashWindowPhase(self) -> None: def startSplashWindowPhase(self) -> None:
"""Runs preparations that needs to be done before the starting process."""
super().startSplashWindowPhase() super().startSplashWindowPhase()
if not self.getIsHeadLess(): if not self.getIsHeadLess():
try:
self.setWindowIcon(QIcon(Resources.getPath(Resources.Images, "cura-icon.png"))) self.setWindowIcon(QIcon(Resources.getPath(Resources.Images, "cura-icon.png")))
except FileNotFoundError:
Logger.log("w", "Unable to find the window icon.")
self.setRequiredPlugins([ self.setRequiredPlugins([
# Misc.: # Misc.:
@ -523,7 +528,7 @@ class CuraApplication(QtApplication):
# Set the setting version for Preferences # Set the setting version for Preferences
preferences = self.getPreferences() preferences = self.getPreferences()
preferences.addPreference("metadata/setting_version", 0) 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") preferences.addPreference("cura/active_mode", "simple")
@ -627,12 +632,13 @@ class CuraApplication(QtApplication):
def callConfirmExitDialogCallback(self, yes_or_no: bool) -> None: def callConfirmExitDialogCallback(self, yes_or_no: bool) -> None:
self._confirm_exit_dialog_callback(yes_or_no) self._confirm_exit_dialog_callback(yes_or_no)
## Signal to connect preferences action in QML
showPreferencesWindow = pyqtSignal() showPreferencesWindow = pyqtSignal()
"""Signal to connect preferences action in QML"""
## Show the preferences window
@pyqtSlot() @pyqtSlot()
def showPreferences(self) -> None: def showPreferences(self) -> None:
"""Show the preferences window"""
self.showPreferencesWindow.emit() self.showPreferencesWindow.emit()
# This is called by drag-and-dropping curapackage files. # This is called by drag-and-dropping curapackage files.
@ -646,14 +652,13 @@ class CuraApplication(QtApplication):
return self._global_container_stack return self._global_container_stack
@override(Application) @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...")) self._setLoadingHint(self._i18n_catalog.i18nc("@info:progress", "Initializing Active Machine..."))
super().setGlobalContainerStack(stack) super().setGlobalContainerStack(stack)
## A reusable dialogbox
#
showMessageBox = pyqtSignal(str,str, str, str, int, int, showMessageBox = pyqtSignal(str,str, str, str, int, int,
arguments = ["title", "text", "informativeText", "detailedText","buttons", "icon"]) arguments = ["title", "text", "informativeText", "detailedText","buttons", "icon"])
"""A reusable dialogbox"""
def messageBox(self, title, text, def messageBox(self, title, text,
informativeText = "", informativeText = "",
@ -706,15 +711,20 @@ class CuraApplication(QtApplication):
self._message_box_callback = None self._message_box_callback = None
self._message_box_callback_arguments = [] 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. # Cura has multiple locations where instance containers need to be saved, so we need to handle this differently.
def saveSettings(self) -> None: 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. # Do not do saving during application start or when data should not be saved on quit.
return return
ContainerRegistry.getInstance().saveDirtyContainers() ContainerRegistry.getInstance().saveDirtyContainers()
self.savePreferences() self.savePreferences()
def saveStack(self, stack): def saveStack(self, stack):
if not self._enable_save:
return
ContainerRegistry.getInstance().saveContainer(stack) ContainerRegistry.getInstance().saveContainer(stack)
@pyqtSlot(str, result = QUrl) @pyqtSlot(str, result = QUrl)
@ -726,9 +736,12 @@ class CuraApplication(QtApplication):
def setDefaultPath(self, key, default_path): def setDefaultPath(self, key, default_path):
self.getPreferences().setValue("local_file/%s" % key, QUrl(default_path).toLocalFile()) 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: 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.setCheckIfTrusted(ApplicationMetadata.IsEnterpriseVersion)
self._plugin_registry.addType("profile_reader", self._addProfileReader) self._plugin_registry.addType("profile_reader", self._addProfileReader)
@ -743,7 +756,6 @@ class CuraApplication(QtApplication):
if not hasattr(sys, "frozen"): if not hasattr(sys, "frozen"):
self._plugin_registry.addPluginLocation(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "plugins")) self._plugin_registry.addPluginLocation(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "plugins"))
self._plugin_registry.loadPlugin("ConsoleLogger") self._plugin_registry.loadPlugin("ConsoleLogger")
self._plugin_registry.loadPlugin("CuraEngineBackend")
self._plugin_registry.loadPlugins() self._plugin_registry.loadPlugins()
@ -752,9 +764,12 @@ class CuraApplication(QtApplication):
self._plugins_loaded = True 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): 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: if self.started:
Logger.info(hint) Logger.info(hint)
else: else:
@ -801,6 +816,7 @@ class CuraApplication(QtApplication):
self._output_device_manager.start() self._output_device_manager.start()
self._welcome_pages_model.initialize() self._welcome_pages_model.initialize()
self._add_printer_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() self._whats_new_pages_model.initialize()
# Detect in which mode to run and execute that mode # Detect in which mode to run and execute that mode
@ -838,13 +854,16 @@ class CuraApplication(QtApplication):
self.callLater(self._openFile, file_name) self.callLater(self._openFile, file_name)
initializationFinished = pyqtSignal() 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): def runWithoutGUI(self):
"""Run Cura without GUI elements and interaction (server mode)."""
self.closeSplash() self.closeSplash()
## Run Cura with GUI (desktop mode).
def runWithGUI(self): def runWithGUI(self):
"""Run Cura with GUI (desktop mode)."""
self._setLoadingHint(self._i18n_catalog.i18nc("@info:progress", "Setting up scene...")) self._setLoadingHint(self._i18n_catalog.i18nc("@info:progress", "Setting up scene..."))
controller = self.getController() controller = self.getController()
@ -873,6 +892,7 @@ class CuraApplication(QtApplication):
# Initialize camera tool # Initialize camera tool
camera_tool = controller.getTool("CameraTool") camera_tool = controller.getTool("CameraTool")
if camera_tool:
camera_tool.setOrigin(Vector(0, 100, 0)) camera_tool.setOrigin(Vector(0, 100, 0))
camera_tool.setZoomRange(0.1, 2000) camera_tool.setZoomRange(0.1, 2000)
@ -901,6 +921,10 @@ class CuraApplication(QtApplication):
def getDiscoveredPrintersModel(self, *args) -> "DiscoveredPrintersModel": def getDiscoveredPrintersModel(self, *args) -> "DiscoveredPrintersModel":
return self._discovered_printer_model return self._discovered_printer_model
@pyqtSlot(result=QObject)
def getDiscoveredCloudPrintersModel(self, *args) -> "DiscoveredCloudPrintersModel":
return self._discovered_cloud_printers_model
@pyqtSlot(result = QObject) @pyqtSlot(result = QObject)
def getFirstStartMachineActionsModel(self, *args) -> "FirstStartMachineActionsModel": def getFirstStartMachineActionsModel(self, *args) -> "FirstStartMachineActionsModel":
if self._first_start_machine_actions_model is None: if self._first_start_machine_actions_model is None:
@ -921,6 +945,10 @@ class CuraApplication(QtApplication):
def getAddPrinterPagesModel(self, *args) -> "AddPrinterPagesModel": def getAddPrinterPagesModel(self, *args) -> "AddPrinterPagesModel":
return self._add_printer_pages_model 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) @pyqtSlot(result = QObject)
def getWhatsNewPagesModel(self, *args) -> "WhatsNewPagesModel": def getWhatsNewPagesModel(self, *args) -> "WhatsNewPagesModel":
return self._whats_new_pages_model return self._whats_new_pages_model
@ -994,10 +1022,13 @@ class CuraApplication(QtApplication):
self._setting_inheritance_manager = SettingInheritanceManager.createSettingInheritanceManager() self._setting_inheritance_manager = SettingInheritanceManager.createSettingInheritanceManager()
return self._setting_inheritance_manager 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: 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) return cast(MachineActionManager.MachineActionManager, self._machine_action_manager)
@pyqtSlot(result = QObject) @pyqtSlot(result = QObject)
@ -1017,8 +1048,9 @@ class CuraApplication(QtApplication):
self._simple_mode_settings_manager = SimpleModeSettingsManager() self._simple_mode_settings_manager = SimpleModeSettingsManager()
return self._simple_mode_settings_manager return self._simple_mode_settings_manager
## Handle Qt events
def event(self, event): def event(self, event):
"""Handle Qt events"""
if event.type() == QEvent.FileOpen: if event.type() == QEvent.FileOpen:
if self._plugins_loaded: if self._plugins_loaded:
self._openFile(event.file()) self._openFile(event.file())
@ -1030,8 +1062,9 @@ class CuraApplication(QtApplication):
def getAutoSave(self) -> Optional[AutoSave]: def getAutoSave(self) -> Optional[AutoSave]:
return self._auto_save return self._auto_save
## Get print information (duration / material used)
def getPrintInformation(self): def getPrintInformation(self):
"""Get print information (duration / material used)"""
return self._print_information return self._print_information
def getQualityProfilesDropDownMenuModel(self, *args, **kwargs): def getQualityProfilesDropDownMenuModel(self, *args, **kwargs):
@ -1047,10 +1080,12 @@ class CuraApplication(QtApplication):
def getCuraAPI(self, *args, **kwargs) -> "CuraAPI": def getCuraAPI(self, *args, **kwargs) -> "CuraAPI":
return self._cura_API return self._cura_API
## Registers objects for the QML engine to use.
#
# \param engine The QML engine.
def registerObjects(self, engine): def registerObjects(self, engine):
"""Registers objects for the QML engine to use.
:param engine: The QML engine.
"""
super().registerObjects(engine) super().registerObjects(engine)
# global contexts # global contexts
@ -1099,6 +1134,7 @@ class CuraApplication(QtApplication):
self.processEvents() self.processEvents()
qmlRegisterType(DiscoveredPrintersModel, "Cura", 1, 0, "DiscoveredPrintersModel") qmlRegisterType(DiscoveredPrintersModel, "Cura", 1, 0, "DiscoveredPrintersModel")
qmlRegisterType(DiscoveredCloudPrintersModel, "Cura", 1, 7, "DiscoveredCloudPrintersModel")
qmlRegisterSingletonType(QualityProfilesDropDownMenuModel, "Cura", 1, 0, qmlRegisterSingletonType(QualityProfilesDropDownMenuModel, "Cura", 1, 0,
"QualityProfilesDropDownMenuModel", self.getQualityProfilesDropDownMenuModel) "QualityProfilesDropDownMenuModel", self.getQualityProfilesDropDownMenuModel)
qmlRegisterSingletonType(CustomQualityProfilesDropDownMenuModel, "Cura", 1, 0, qmlRegisterSingletonType(CustomQualityProfilesDropDownMenuModel, "Cura", 1, 0,
@ -1121,6 +1157,7 @@ class CuraApplication(QtApplication):
from cura.API import CuraAPI from cura.API import CuraAPI
qmlRegisterSingletonType(CuraAPI, "Cura", 1, 1, "API", self.getCuraAPI) 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. # 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"))) actions_url = QUrl.fromLocalFile(os.path.abspath(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles, "Actions.qml")))
@ -1184,8 +1221,9 @@ class CuraApplication(QtApplication):
if node is not None and (node.getMeshData() is not None or node.callDecoration("getLayerData")): if node is not None and (node.getMeshData() is not None or node.callDecoration("getLayerData")):
self._update_platform_activity_timer.start() self._update_platform_activity_timer.start()
## Update scene bounding box for current build plate
def updatePlatformActivity(self, node = None): def updatePlatformActivity(self, node = None):
"""Update scene bounding box for current build plate"""
count = 0 count = 0
scene_bounding_box = None scene_bounding_box = None
is_block_slicing_node = False is_block_slicing_node = False
@ -1229,9 +1267,10 @@ class CuraApplication(QtApplication):
self._platform_activity = True if count > 0 else False self._platform_activity = True if count > 0 else False
self.activityChanged.emit() self.activityChanged.emit()
## Select all nodes containing mesh data in the scene.
@pyqtSlot() @pyqtSlot()
def selectAll(self): def selectAll(self):
"""Select all nodes containing mesh data in the scene."""
if not self.getController().getToolsEnabled(): if not self.getController().getToolsEnabled():
return return
@ -1250,9 +1289,10 @@ class CuraApplication(QtApplication):
Selection.add(node) Selection.add(node)
## Reset all translation on nodes with mesh data.
@pyqtSlot() @pyqtSlot()
def resetAllTranslation(self): def resetAllTranslation(self):
"""Reset all translation on nodes with mesh data."""
Logger.log("i", "Resetting all scene translations") Logger.log("i", "Resetting all scene translations")
nodes = [] nodes = []
for node in DepthFirstIterator(self.getController().getScene().getRoot()): for node in DepthFirstIterator(self.getController().getScene().getRoot()):
@ -1278,9 +1318,10 @@ class CuraApplication(QtApplication):
op.addOperation(SetTransformOperation(node, Vector(0, center_y, 0))) op.addOperation(SetTransformOperation(node, Vector(0, center_y, 0)))
op.push() op.push()
## Reset all transformations on nodes with mesh data.
@pyqtSlot() @pyqtSlot()
def resetAll(self): def resetAll(self):
"""Reset all transformations on nodes with mesh data."""
Logger.log("i", "Resetting all scene transformations") Logger.log("i", "Resetting all scene transformations")
nodes = [] nodes = []
for node in DepthFirstIterator(self.getController().getScene().getRoot()): for node in DepthFirstIterator(self.getController().getScene().getRoot()):
@ -1306,9 +1347,10 @@ class CuraApplication(QtApplication):
op.addOperation(SetTransformOperation(node, Vector(0, center_y, 0), Quaternion(), Vector(1, 1, 1))) op.addOperation(SetTransformOperation(node, Vector(0, center_y, 0), Quaternion(), Vector(1, 1, 1)))
op.push() op.push()
## Arrange all objects.
@pyqtSlot() @pyqtSlot()
def arrangeObjectsToAllBuildPlates(self) -> None: def arrangeObjectsToAllBuildPlates(self) -> None:
"""Arrange all objects."""
nodes_to_arrange = [] nodes_to_arrange = []
for node in DepthFirstIterator(self.getController().getScene().getRoot()): for node in DepthFirstIterator(self.getController().getScene().getRoot()):
if not isinstance(node, SceneNode): if not isinstance(node, SceneNode):
@ -1361,17 +1403,21 @@ class CuraApplication(QtApplication):
nodes_to_arrange.append(node) nodes_to_arrange.append(node)
self.arrange(nodes_to_arrange, fixed_nodes = []) self.arrange(nodes_to_arrange, fixed_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: 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 min_offset = self.getBuildVolume().getEdgeDisallowedSize() + 2 # Allow for some rounding errors
job = ArrangeObjectsJob(nodes, fixed_nodes, min_offset = max(min_offset, 8)) job = ArrangeObjectsJob(nodes, fixed_nodes, min_offset = max(min_offset, 8))
job.start() job.start()
## Reload all mesh data on the screen from file.
@pyqtSlot() @pyqtSlot()
def reloadAll(self) -> None: def reloadAll(self) -> None:
"""Reload all mesh data on the screen from file."""
Logger.log("i", "Reloading all loaded mesh data.") Logger.log("i", "Reloading all loaded mesh data.")
nodes = [] nodes = []
has_merged_nodes = False has_merged_nodes = False
@ -1397,12 +1443,21 @@ class CuraApplication(QtApplication):
if not nodes: if not nodes:
return return
objects_in_filename = {} # type: Dict[str, List[CuraSceneNode]]
for node in nodes: for node in nodes:
mesh_data = node.getMeshData() mesh_data = node.getMeshData()
if mesh_data: if mesh_data:
file_name = mesh_data.getFileName() file_name = mesh_data.getFileName()
if file_name: if file_name:
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 = ReadMeshJob(file_name)
job._node = node # type: ignore job._node = node # type: ignore
job.finished.connect(self._reloadMeshFinished) job.finished.connect(self._reloadMeshFinished)
@ -1410,8 +1465,6 @@ class CuraApplication(QtApplication):
job.finished.connect(self.updateOriginOfMergedMeshes) job.finished.connect(self.updateOriginOfMergedMeshes)
job.start() job.start()
else:
Logger.log("w", "Unable to reload data because we don't have a filename.")
@pyqtSlot("QStringList") @pyqtSlot("QStringList")
def setExpandedCategories(self, categories: List[str]) -> None: def setExpandedCategories(self, categories: List[str]) -> None:
@ -1474,8 +1527,9 @@ class CuraApplication(QtApplication):
group_node.setName("MergedMesh") # add a specific name to distinguish this node group_node.setName("MergedMesh") # add a specific name to distinguish this node
## Updates origin position of all merged meshes
def updateOriginOfMergedMeshes(self, _): def updateOriginOfMergedMeshes(self, _):
"""Updates origin position of all merged meshes"""
group_nodes = [] group_nodes = []
for node in DepthFirstIterator(self.getController().getScene().getRoot()): for node in DepthFirstIterator(self.getController().getScene().getRoot()):
if isinstance(node, CuraSceneNode) and node.getName() == "MergedMesh": if isinstance(node, CuraSceneNode) and node.getName() == "MergedMesh":
@ -1587,13 +1641,31 @@ class CuraApplication(QtApplication):
fileLoaded = pyqtSignal(str) fileLoaded = pyqtSignal(str)
fileCompleted = pyqtSignal(str) fileCompleted = pyqtSignal(str)
def _reloadMeshFinished(self, job): def _reloadMeshFinished(self, job) -> None:
# TODO; This needs to be fixed properly. We now make the assumption that we only load a single mesh! """
job_result = job.getResult() 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: if len(job_result) == 0:
Logger.log("e", "Reloading the mesh failed.") Logger.log("e", "Reloading the mesh failed.")
return 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: if not mesh_data:
Logger.log("w", "Could not find a mesh in reloaded node.") Logger.log("w", "Could not find a mesh in reloaded node.")
return return
@ -1624,12 +1696,15 @@ class CuraApplication(QtApplication):
def additionalComponents(self): def additionalComponents(self):
return self._additional_components 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") @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: if area_id not in self._additional_components:
self._additional_components[area_id] = [] self._additional_components[area_id] = []
self._additional_components[area_id].append(component) self._additional_components[area_id].append(component)
@ -1644,10 +1719,13 @@ class CuraApplication(QtApplication):
@pyqtSlot(QUrl, str) @pyqtSlot(QUrl, str)
@pyqtSlot(QUrl) @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): 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.
"""
if not file.isValid(): if not file.isValid():
return return
@ -1716,6 +1794,9 @@ class CuraApplication(QtApplication):
if not global_container_stack: if not global_container_stack:
Logger.log("w", "Can't load meshes before a printer is added.") Logger.log("w", "Can't load meshes before a printer is added.")
return return
if not self._volume:
Logger.log("w", "Can't load meshes before the build volume is initialized")
return
nodes = job.getResult() nodes = job.getResult()
file_name = job.getFileName() file_name = job.getFileName()
@ -1789,7 +1870,7 @@ class CuraApplication(QtApplication):
# If a model is to small then it will not contain any points # 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: 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."), Message(self._i18n_catalog.i18nc("@info:status", "The selected model was too small to load."),
title=self._i18n_catalog.i18nc("@info:title", "Warning")).show() title = self._i18n_catalog.i18nc("@info:title", "Warning")).show()
return return
# Step is for skipping tests to make it a lot faster. it also makes the outcome somewhat rougher # Step is for skipping tests to make it a lot faster. it also makes the outcome somewhat rougher
@ -1820,9 +1901,8 @@ class CuraApplication(QtApplication):
@pyqtSlot(str, result=bool) @pyqtSlot(str, result=bool)
def checkIsValidProjectFile(self, file_url): 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() file_path = QUrl(file_url).toLocalFile()
workspace_reader = self.getWorkspaceFileHandler().getReaderForFile(file_path) workspace_reader = self.getWorkspaceFileHandler().getReaderForFile(file_path)
if workspace_reader is None: if workspace_reader is None:
@ -1840,7 +1920,6 @@ class CuraApplication(QtApplication):
return return
selection_pass = cast(SelectionPass, self.getRenderer().getRenderPass("selection")) 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. 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 return
node = self.getController().getScene().findObject(selection_pass.getIdAtPosition(x, y)) node = self.getController().getScene().findObject(selection_pass.getIdAtPosition(x, y))
if not node: if not node:

View file

@ -24,11 +24,15 @@ class CuraPackageManager(PackageManager):
super().initialize() 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]]]: 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) ids = self.getPackageContainerIds(package_id)
container_stacks = self._application.getContainerRegistry().findContainerStacks() container_stacks = self._application.getContainerRegistry().findContainerStacks()
global_stacks = [container_stack for container_stack in container_stacks if isinstance(container_stack, GlobalStack)] 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 = [] machine_with_qualities = []
for container_id in ids: for container_id in ids:
for global_stack in global_stacks: 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")): 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(): 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 return machine_with_materials, machine_with_qualities

View file

@ -3,9 +3,12 @@
from UM.Mesh.MeshData import MeshData 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 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, 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): center_position = None, layers=None, element_counts=None, attributes=None):
super().__init__(vertices=vertices, normals=normals, indices=indices, colors=colors, uvs=uvs, super().__init__(vertices=vertices, normals=normals, indices=indices, colors=colors, uvs=uvs,

View file

@ -10,8 +10,9 @@ import numpy
from typing import Dict, Optional from typing import Dict, Optional
## Builder class for constructing a LayerData object
class LayerDataBuilder(MeshBuilder): class LayerDataBuilder(MeshBuilder):
"""Builder class for constructing a :py:class:`cura.LayerData.LayerData` object"""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._layers = {} # type: Dict[int, Layer] self._layers = {} # type: Dict[int, Layer]
@ -42,11 +43,13 @@ class LayerDataBuilder(MeshBuilder):
self._layers[layer].setThickness(thickness) 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): 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 vertex_count = 0
index_count = 0 index_count = 0
for layer, data in self._layers.items(): for layer, data in self._layers.items():

View file

@ -7,8 +7,9 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
from cura.LayerData import LayerData from cura.LayerData import LayerData
## Simple decorator to indicate a scene node holds layer data.
class LayerDataDecorator(SceneNodeDecorator): class LayerDataDecorator(SceneNodeDecorator):
"""Simple decorator to indicate a scene node holds layer data."""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._layer_data = None # type: Optional[LayerData] self._layer_data = None # type: Optional[LayerData]

View file

@ -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) __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: 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._extruder = extruder
self._types = line_types self._types = line_types
for i in range(len(self._types)): for i in range(len(self._types)):
@ -83,19 +86,22 @@ class LayerPolygon:
self._vertex_begin = 0 self._vertex_begin = 0
self._vertex_end = numpy.sum( self._build_cache_needed_points ) 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: 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: if self._build_cache_line_mesh_mask is None or self._build_cache_needed_points is None:
self.buildCache() self.buildCache()
@ -202,8 +208,12 @@ class LayerPolygon:
def jumpCount(self): def jumpCount(self):
return self._jump_count return self._jump_count
# Calculate normals for the entire polygon using numpy.
def getNormals(self) -> numpy.ndarray: 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 = numpy.copy(self._data)
normals[:, 1] = 0.0 # We are only interested in 2D normals normals[:, 1] = 0.0 # We are only interested in 2D normals
@ -229,9 +239,10 @@ class LayerPolygon:
__color_map = None # type: numpy.ndarray __color_map = None # type: numpy.ndarray
## Gets the instance of the VersionUpgradeManager, or creates one.
@classmethod @classmethod
def getColorMap(cls) -> numpy.ndarray: def getColorMap(cls) -> numpy.ndarray:
"""Gets the instance of the VersionUpgradeManager, or creates one."""
if cls.__color_map is None: if cls.__color_map is None:
theme = cast(Theme, QtApplication.getInstance().getTheme()) theme = cast(Theme, QtApplication.getInstance().getTheme())
cls.__color_map = numpy.array([ cls.__color_map = numpy.array([

View file

@ -11,16 +11,22 @@ from UM.PluginObject import PluginObject
from UM.PluginRegistry import PluginRegistry 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): 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: 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__() super().__init__()
self._key = key self._key = key
self._label = label self._label = label
@ -34,10 +40,14 @@ class MachineAction(QObject, PluginObject):
def getKey(self) -> str: def getKey(self) -> str:
return self._key 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: 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 return True
@pyqtProperty(str, notify = labelChanged) @pyqtProperty(str, notify = labelChanged)
@ -49,17 +59,24 @@ class MachineAction(QObject, PluginObject):
self._label = label self._label = label
self.labelChanged.emit() 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() @pyqtSlot()
def reset(self) -> None: 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._finished = False
self._reset() self._reset()
## Protected implementation of reset.
# /sa reset()
def _reset(self) -> None: def _reset(self) -> None:
"""Protected implementation of reset.
See also :py:meth:`cura.MachineAction.MachineAction.reset`
"""
pass pass
@pyqtSlot() @pyqtSlot()
@ -72,8 +89,9 @@ class MachineAction(QObject, PluginObject):
def finished(self) -> bool: def finished(self) -> bool:
return self._finished return self._finished
## Protected helper to create a view object based on provided QML.
def _createViewFromQML(self) -> Optional["QObject"]: def _createViewFromQML(self) -> Optional["QObject"]:
"""Protected helper to create a view object based on provided QML."""
plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId())
if plugin_path is None: if plugin_path is None:
Logger.log("e", "Cannot create QML view: cannot find plugin path for plugin [%s]", self.getPluginId()) Logger.log("e", "Cannot create QML view: cannot find plugin path for plugin [%s]", self.getPluginId())

View file

@ -9,47 +9,59 @@ from UM.Logger import Logger
from UM.Settings.InstanceContainer import InstanceContainer 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: class ContainerNode:
## Creates a new node for the container tree. """A node in the container tree. It represents one container.
# \param container_id The ID of the container that this node should
# represent. 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: 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_id = container_id
self._container = None # type: Optional[InstanceContainer] self._container = None # type: Optional[InstanceContainer]
self.children_map = {} # type: Dict[str, ContainerNode] # Mapping from container ID to container node. 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]: 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] 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: 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) container_metadata = ContainerRegistry.getInstance().findContainersMetadata(id = self.container_id)
if len(container_metadata) == 0: if len(container_metadata) == 0:
return default return default
return container_metadata[0].get(entry, 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 @property
def container(self) -> Optional[InstanceContainer]: 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: if not self._container:
container_list = ContainerRegistry.getInstance().findInstanceContainers(id = self.container_id) container_list = ContainerRegistry.getInstance().findInstanceContainers(id = self.container_id)
if len(container_list) == 0: if len(container_list) == 0:

View file

@ -19,17 +19,16 @@ if TYPE_CHECKING:
from UM.Settings.ContainerStack import ContainerStack 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: 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"] __instance = None # type: Optional["ContainerTree"]
@classmethod @classmethod
@ -43,13 +42,15 @@ class ContainerTree:
self.materialsChanged = Signal() # Emitted when any of the material nodes in the tree got changed. 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. 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"]: 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() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if global_stack is None: if global_stack is None:
return {} return {}
@ -58,14 +59,15 @@ class ContainerTree:
extruder_enabled = [extruder.isEnabled for extruder in global_stack.extruderList] extruder_enabled = [extruder.isEnabled for extruder in global_stack.extruderList]
return self.machines[global_stack.definition.getId()].getQualityGroups(variant_names, material_bases, extruder_enabled) 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"]: 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() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if global_stack is None: if global_stack is None:
return [] return []
@ -74,31 +76,43 @@ class ContainerTree:
extruder_enabled = [extruder.isEnabled for extruder in global_stack.extruderList] extruder_enabled = [extruder.isEnabled for extruder in global_stack.extruderList]
return self.machines[global_stack.definition.getId()].getQualityChangesGroups(variant_names, material_bases, extruder_enabled) 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: def _onStartupFinished(self) -> None:
"""Ran after completely starting up the application."""
currently_added = ContainerRegistry.getInstance().findContainerStacks() # Find all currently added global stacks. currently_added = ContainerRegistry.getInstance().findContainerStacks() # Find all currently added global stacks.
JobQueue.getInstance().add(self._MachineNodeLoadJob(self, currently_added)) JobQueue.getInstance().add(self._MachineNodeLoadJob(self, currently_added))
## Dictionary-like object that contains the machines.
#
# This handles the lazy loading of MachineNodes.
class _MachineNodeMap: class _MachineNodeMap:
"""Dictionary-like object that contains the machines.
This handles the lazy loading of MachineNodes.
"""
def __init__(self) -> None: def __init__(self) -> None:
self._machines = {} # type: Dict[str, MachineNode] 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: 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 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: 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: if definition_id not in self._machines:
start_time = time.time() start_time = time.time()
self._machines[definition_id] = MachineNode(definition_id) 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)) 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] 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]: 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: if definition_id not in self:
return default return default
return self[definition_id] 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: 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 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): class _MachineNodeLoadJob(Job):
## Creates a new background task. """Pre-loads all currently added printers as a background task so that switching printers in the interface is
# \param tree_root The container tree instance. This cannot be faster.
# 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.
def __init__(self, tree_root: "ContainerTree", container_stacks: List["ContainerStack"]) -> None: 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.tree_root = tree_root
self.container_stacks = container_stacks self.container_stacks = container_stacks
super().__init__() super().__init__()
## Starts the background task.
#
# The ``JobQueue`` will schedule this on a different thread.
def run(self) -> None: 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. for stack in self.container_stacks: # Load all currently-added containers.
if not isinstance(stack, GlobalStack): if not isinstance(stack, GlobalStack):
continue continue
@ -156,3 +182,4 @@ class ContainerTree:
definition_id = stack.definition.getId() definition_id = stack.definition.getId()
if not self.tree_root.machines.is_loaded(definition_id): if not self.tree_root.machines.is_loaded(definition_id):
_ = self.tree_root.machines[definition_id] _ = self.tree_root.machines[definition_id]
Logger.log("d", "All MachineNode loading completed")

View file

@ -11,10 +11,12 @@ if TYPE_CHECKING:
from cura.Machines.QualityNode import QualityNode 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): 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: def __init__(self, container_id: str, quality: "QualityNode") -> None:
super().__init__(container_id) super().__init__(container_id)
self.quality = quality self.quality = quality

View file

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
import time import time
@ -13,16 +13,17 @@ from UM.Settings.SettingDefinition import SettingDefinition
from UM.Settings.Validator import ValidatorState from UM.Settings.Validator import ValidatorState
import cura.CuraApplication 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): 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: def __init__(self, parent: Optional[QObject] = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -50,6 +51,8 @@ class MachineErrorChecker(QObject):
self._error_check_timer.setInterval(100) self._error_check_timer.setInterval(100)
self._error_check_timer.setSingleShot(True) self._error_check_timer.setSingleShot(True)
self._keys_to_check = set() # type: Set[str]
def initialize(self) -> None: def initialize(self) -> None:
self._error_check_timer.timeout.connect(self._rescheduleCheck) self._error_check_timer.timeout.connect(self._rescheduleCheck)
@ -92,24 +95,38 @@ class MachineErrorChecker(QObject):
def needToWaitForResult(self) -> bool: def needToWaitForResult(self) -> bool:
return self._need_to_check or self._check_in_progress 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: 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": if property_name != "value":
return return
self._keys_to_check.add(key)
self.startErrorCheck() self.startErrorCheck()
# Starts the error check timer to schedule a new error check.
def startErrorCheck(self, *args: Any) -> None: 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: if not self._check_in_progress:
self._need_to_check = True self._need_to_check = True
self.needToWaitForResultChanged.emit() self.needToWaitForResultChanged.emit()
self._error_check_timer.start() 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: 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: if self._check_in_progress and not self._need_to_check:
self._need_to_check = True self._need_to_check = True
self.needToWaitForResultChanged.emit() self.needToWaitForResultChanged.emit()
@ -127,7 +144,10 @@ class MachineErrorChecker(QObject):
# Populate the (stack, key) tuples to check # Populate the (stack, key) tuples to check
self._stacks_and_keys_to_check = deque() self._stacks_and_keys_to_check = deque()
for stack in global_stack.extruderList: 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._stacks_and_keys_to_check.append((stack, key))
self._application.callLater(self._checkStack) self._application.callLater(self._checkStack)
@ -168,18 +188,25 @@ class MachineErrorChecker(QObject):
validator = validator_type(key) validator = validator_type(key)
validation_state = validator(stack) validation_state = validator(stack)
if validation_state in (ValidatorState.Exception, ValidatorState.MaximumError, ValidatorState.MinimumError, ValidatorState.Invalid): if validation_state in (ValidatorState.Exception, ValidatorState.MaximumError, ValidatorState.MinimumError, ValidatorState.Invalid):
# Finish # Since we don't know if any of the settings we didn't check is has an error value, store the list for the
self._setResult(True) # 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 return
# Schedule the check for the next key # Schedule the check for the next key
self._application.callLater(self._checkStack) 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: if result != self._has_errors:
self._has_errors = result self._has_errors = result
self.hasErrorUpdated.emit() self.hasErrorUpdated.emit()
self._machine_manager.stacksValidationChanged.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._need_to_check = False
self._check_in_progress = False self._check_in_progress = False
self.needToWaitForResultChanged.emit() self.needToWaitForResultChanged.emit()

View file

@ -17,10 +17,12 @@ from cura.Machines.VariantNode import VariantNode
import UM.FlameProfiler import UM.FlameProfiler
## This class represents a machine in the container tree.
#
# The subnodes of these nodes are variants.
class MachineNode(ContainerNode): 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: def __init__(self, container_id: str) -> None:
super().__init__(container_id) super().__init__(container_id)
self.variants = {} # type: Dict[str, VariantNode] # Mapping variant names to their nodes. self.variants = {} # type: Dict[str, VariantNode] # Mapping variant names to their nodes.
@ -47,20 +49,21 @@ class MachineNode(ContainerNode):
self._loadAll() 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]: 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): 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)) + ").") 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 {} return {}
@ -98,28 +101,26 @@ class MachineNode(ContainerNode):
quality_groups[quality_type].is_available = True quality_groups[quality_type].is_available = True
return quality_groups 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]: 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. 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. 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"], groups_by_name[name] = QualityChangesGroup(name, quality_type = quality_changes["quality_type"],
intent_category = quality_changes.get("intent_category", "default"), intent_category = quality_changes.get("intent_category", "default"),
parent = CuraApplication.getInstance()) 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. 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") groups_by_name[name].intent_category = quality_changes.get("intent_category", "default")
@ -145,20 +144,33 @@ class MachineNode(ContainerNode):
else: # Global profile. else: # Global profile.
groups_by_name[name].metadata_for_global = quality_changes 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()) 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": 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()))) 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 @UM.FlameProfiler.profile
def _loadAll(self) -> None: def _loadAll(self) -> None:
"""(Re)loads all variants under this printer."""
container_registry = ContainerRegistry.getInstance() container_registry = ContainerRegistry.getInstance()
if not self.has_variants: if not self.has_variants:
self.variants["empty"] = VariantNode("empty_variant", machine = self) self.variants["empty"] = VariantNode("empty_variant", machine = self)
@ -171,6 +183,10 @@ class MachineNode(ContainerNode):
if variant_name not in self.variants: if variant_name not in self.variants:
self.variants[variant_name] = VariantNode(variant["id"], machine = self) self.variants[variant_name] = VariantNode(variant["id"], machine = self)
self.variants[variant_name].materialsChanged.connect(self.materialsChanged) 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: if not self.variants:
self.variants["empty"] = VariantNode("empty_variant", machine = self) self.variants["empty"] = VariantNode("empty_variant", machine = self)

View file

@ -7,18 +7,21 @@ if TYPE_CHECKING:
from cura.Machines.MaterialNode import MaterialNode 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: 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") __slots__ = ("name", "is_read_only", "root_material_node", "derived_material_node_list")
def __init__(self, name: str, root_material_node: "MaterialNode") -> None: def __init__(self, name: str, root_material_node: "MaterialNode") -> None:

View file

@ -15,10 +15,12 @@ if TYPE_CHECKING:
from cura.Machines.VariantNode import VariantNode from cura.Machines.VariantNode import VariantNode
## Represents a material in the container tree.
#
# Its subcontainers are quality profiles.
class MaterialNode(ContainerNode): class MaterialNode(ContainerNode):
"""Represents a material in the container tree.
Its subcontainers are quality profiles.
"""
def __init__(self, container_id: str, variant: "VariantNode") -> None: def __init__(self, container_id: str, variant: "VariantNode") -> None:
super().__init__(container_id) super().__init__(container_id)
self.variant = variant self.variant = variant
@ -34,16 +36,16 @@ class MaterialNode(ContainerNode):
container_registry.containerRemoved.connect(self._onRemoved) container_registry.containerRemoved.connect(self._onRemoved)
container_registry.containerMetaDataChanged.connect(self._onMetadataChanged) 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: 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(): for quality_id, quality_node in self.qualities.items():
if self.variant.machine.preferred_quality_type == quality_node.quality_type: if self.variant.machine.preferred_quality_type == quality_node.quality_type:
return quality_node return quality_node
@ -107,10 +109,13 @@ class MaterialNode(ContainerNode):
if not self.qualities: if not self.qualities:
self.qualities["empty_quality"] = QualityNode("empty_quality", parent = self) 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: 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: if container.getId() == self.container_id:
# Remove myself from my parent. # Remove myself from my parent.
if self.base_file in self.variant.materials: 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.variant.materials["empty_material"] = MaterialNode("empty_material", variant = self.variant)
self.materialChanged.emit(self) 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: 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: if container.getId() != self.container_id:
return return

View file

@ -13,11 +13,13 @@ from cura.Machines.ContainerTree import ContainerTree
from cura.Machines.MaterialNode import MaterialNode from cura.Machines.MaterialNode import MaterialNode
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry 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): 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() extruderPositionChanged = pyqtSignal()
enabledChanged = pyqtSignal() enabledChanged = pyqtSignal()
@ -121,10 +123,13 @@ class BaseMaterialsModel(ListModel):
def enabled(self): def enabled(self):
return self._enabled 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: 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: if self._extruder_stack is None:
return return
if material.variant.container_id != self._extruder_stack.variant.getId(): if material.variant.container_id != self._extruder_stack.variant.getId():
@ -136,23 +141,25 @@ class BaseMaterialsModel(ListModel):
return return
self._onChanged() self._onChanged()
## Triggered when the list of favorite materials is changed.
def _favoritesChanged(self, material_base_file: str) -> None: 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: if material_base_file in self._available_materials:
self._onChanged() self._onChanged()
## This is an abstract method that needs to be implemented by the specific
# models themselves.
def _update(self): 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(";")) 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. # Update the available materials (ContainerNode) for the current active machine and extruder setup.
global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() 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. return # There are no materials for this machine, so nothing to do.
extruder_stack = global_stack.extruders.get(str(self._extruder_position)) extruder_list = global_stack.extruderList
if not extruder_stack: if self._extruder_position > len(extruder_list):
return return
extruder_stack = extruder_list[self._extruder_position]
nozzle_name = extruder_stack.variant.getName() nozzle_name = extruder_stack.variant.getName()
machine_node = ContainerTree.getInstance().machines[global_stack.definition.getId()] machine_node = ContainerTree.getInstance().machines[global_stack.definition.getId()]
if nozzle_name not in machine_node.variants: if nozzle_name not in machine_node.variants:
@ -163,23 +170,23 @@ class BaseMaterialsModel(ListModel):
approximate_material_diameter = extruder_stack.getApproximateMaterialDiameter() 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} 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): 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 global_stack = self._machine_manager.activeMachine
if global_stack is None or not self._enabled: if global_stack is None or not self._enabled:
return False return False
extruder_position = str(self._extruder_position) if self._extruder_position >= len(global_stack.extruderList):
if extruder_position not in global_stack.extruders:
return False return False
return True 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): 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) metadata_list = CuraContainerRegistry.getInstance().findContainersMetadata(id = container_node.container_id)
if not metadata_list: if not metadata_list:
return None return None

View file

@ -14,9 +14,8 @@ if TYPE_CHECKING:
from UM.Settings.Interfaces import ContainerInterface from UM.Settings.Interfaces import ContainerInterface
## This model is used for the custom profile items in the profile drop down
# menu.
class CustomQualityProfilesDropDownMenuModel(QualityProfilesDropDownMenuModel): 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: def __init__(self, parent: Optional["QObject"] = None) -> None:
super().__init__(parent) super().__init__(parent)

View file

@ -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)

View file

@ -72,8 +72,6 @@ class DiscoveredPrinter(QObject):
# Human readable machine type string # Human readable machine type string
@pyqtProperty(str, notify = machineTypeChanged) @pyqtProperty(str, notify = machineTypeChanged)
def readableMachineType(self) -> str: 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 # 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 # "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. # like "Ultimaker 3". The code below handles this case.
@ -117,12 +115,11 @@ class DiscoveredPrinter(QObject):
return catalog.i18nc("@label", "Available networked printers") 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): 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: def __init__(self, application: "CuraApplication", parent: Optional["QObject"] = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -131,6 +128,7 @@ class DiscoveredPrintersModel(QObject):
self._discovered_printer_by_ip_dict = dict() # type: Dict[str, DiscoveredPrinter] self._discovered_printer_by_ip_dict = dict() # type: Dict[str, DiscoveredPrinter]
self._plugin_for_manual_device = None # type: Optional[OutputDevicePlugin] self._plugin_for_manual_device = None # type: Optional[OutputDevicePlugin]
self._network_plugin_queue = [] # type: List[OutputDevicePlugin]
self._manual_device_address = "" self._manual_device_address = ""
self._manual_device_request_timeout_in_seconds = 5 # timeout for adding a manual device in seconds self._manual_device_request_timeout_in_seconds = 5 # timeout for adding a manual device in seconds
@ -155,17 +153,22 @@ class DiscoveredPrintersModel(QObject):
all_plugins_dict = self._application.getOutputDeviceManager().getAllOutputDevicePlugins() 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, lambda plugin_item: plugin_item.canAddManualDevice(address) in priority_order,
all_plugins_dict.values())] 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) Logger.log("d", "Could not find a plugin to accept adding %s manually via address.", address)
return return
plugin = max(can_add_manual_plugins, key = lambda p: priority_order.index(p.canAddManualDevice(address))) self._attemptToAddManualDevice(address)
self._plugin_for_manual_device = plugin
self._plugin_for_manual_device.addManualDevice(address, callback = self._onManualDeviceRequestFinished) 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_address = address
self._manual_device_request_timer.start() self._manual_device_request_timer.start()
self.hasManualDeviceRequestInProgressChanged.emit() self.hasManualDeviceRequestInProgressChanged.emit()
@ -183,8 +186,11 @@ class DiscoveredPrintersModel(QObject):
self.manualDeviceRequestFinished.emit(False) self.manualDeviceRequestFinished.emit(False)
def _onManualRequestTimeout(self) -> None: 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() self.cancelCurrentManualDeviceRequest()
if self._network_plugin_queue:
self._attemptToAddManualDevice(address)
hasManualDeviceRequestInProgressChanged = pyqtSignal() hasManualDeviceRequestInProgressChanged = pyqtSignal()
@ -200,6 +206,8 @@ class DiscoveredPrintersModel(QObject):
self._manual_device_address = "" self._manual_device_address = ""
self.hasManualDeviceRequestInProgressChanged.emit() self.hasManualDeviceRequestInProgressChanged.emit()
self.manualDeviceRequestFinished.emit(success) self.manualDeviceRequestFinished.emit(success)
if not success and self._network_plugin_queue:
self._attemptToAddManualDevice(address)
@pyqtProperty("QVariantMap", notify = discoveredPrintersChanged) @pyqtProperty("QVariantMap", notify = discoveredPrintersChanged)
def discoveredPrintersByAddress(self) -> Dict[str, DiscoveredPrinter]: def discoveredPrintersByAddress(self) -> Dict[str, DiscoveredPrinter]:
@ -256,8 +264,14 @@ class DiscoveredPrintersModel(QObject):
del self._discovered_printer_by_ip_dict[ip_address] del self._discovered_printer_by_ip_dict[ip_address]
self.discoveredPrintersChanged.emit() 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") @pyqtSlot("QVariant")
def createMachineFromDiscoveredPrinter(self, discovered_printer: "DiscoveredPrinter") -> None: 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()) discovered_printer.create_callback(discovered_printer.getKey())

View file

@ -15,27 +15,27 @@ if TYPE_CHECKING:
catalog = i18nCatalog("cura") 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): 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. # The ID of the container stack for the extruder.
IdRole = Qt.UserRole + 1 IdRole = Qt.UserRole + 1
## Human-readable name of the extruder.
NameRole = Qt.UserRole + 2 NameRole = Qt.UserRole + 2
"""Human-readable name of the extruder."""
## Colour of the material loaded in the extruder.
ColorRole = Qt.UserRole + 3 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 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. # The ID of the definition of the extruder.
DefinitionRole = Qt.UserRole + 5 DefinitionRole = Qt.UserRole + 5
@ -50,18 +50,18 @@ class ExtrudersModel(ListModel):
MaterialBrandRole = Qt.UserRole + 9 MaterialBrandRole = Qt.UserRole + 9
ColorNameRole = Qt.UserRole + 10 ColorNameRole = Qt.UserRole + 10
## Is the extruder enabled?
EnabledRole = Qt.UserRole + 11 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"] 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): 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) super().__init__(parent)
self.addRoleName(self.IdRole, "id") self.addRoleName(self.IdRole, "id")
@ -101,14 +101,15 @@ class ExtrudersModel(ListModel):
def addOptionalExtruder(self): def addOptionalExtruder(self):
return self._add_optional_extruder 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): 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() machine_manager = Application.getInstance().getMachineManager()
if machine_id is not None: if machine_id is not None:
if machine_manager.activeMachine is None: if machine_manager.activeMachine is None:
@ -146,11 +147,13 @@ class ExtrudersModel(ListModel):
def _updateExtruders(self): def _updateExtruders(self):
self._update_extruder_timer.start() self._update_extruder_timer.start()
## Update the list of extruders.
#
# This should be called whenever the list of extruders changes.
@UM.FlameProfiler.profile @UM.FlameProfiler.profile
def __updateExtruders(self): def __updateExtruders(self):
"""Update the list of extruders.
This should be called whenever the list of extruders changes.
"""
extruders_changed = False extruders_changed = False
if self.count != 0: if self.count != 0:

View file

@ -4,16 +4,17 @@
from cura.Machines.Models.BaseMaterialsModel import BaseMaterialsModel from cura.Machines.Models.BaseMaterialsModel import BaseMaterialsModel
import cura.CuraApplication # To listen to changes to the preferences. import cura.CuraApplication # To listen to changes to the preferences.
## Model that shows the list of favorite materials.
class FavoriteMaterialsModel(BaseMaterialsModel): class FavoriteMaterialsModel(BaseMaterialsModel):
"""Model that shows the list of favorite materials."""
def __init__(self, parent = None): def __init__(self, parent = None):
super().__init__(parent) super().__init__(parent)
cura.CuraApplication.CuraApplication.getInstance().getPreferences().preferenceChanged.connect(self._onFavoritesChanged) cura.CuraApplication.CuraApplication.getInstance().getPreferences().preferenceChanged.connect(self._onFavoritesChanged)
self._onChanged() 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: 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": if preference_key != "cura/favorite_materials":
return return
self._onChanged() self._onChanged()

View file

@ -11,13 +11,13 @@ if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication 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): 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 TitleRole = Qt.UserRole + 1
ContentRole = Qt.UserRole + 2 ContentRole = Qt.UserRole + 2
@ -73,9 +73,10 @@ class FirstStartMachineActionsModel(ListModel):
self._current_action_index += 1 self._current_action_index += 1
self.currentActionIndexChanged.emit() self.currentActionIndexChanged.emit()
# Resets the current action index to 0 so the wizard panel can show actions from the beginning.
@pyqtSlot() @pyqtSlot()
def reset(self) -> None: 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._current_action_index = 0
self.currentActionIndexChanged.emit() self.currentActionIndexChanged.emit()

View file

@ -19,6 +19,7 @@ class GlobalStacksModel(ListModel):
ConnectionTypeRole = Qt.UserRole + 4 ConnectionTypeRole = Qt.UserRole + 4
MetaDataRole = Qt.UserRole + 5 MetaDataRole = Qt.UserRole + 5
DiscoverySourceRole = Qt.UserRole + 6 # For separating local and remote printers in the machine management page DiscoverySourceRole = Qt.UserRole + 6 # For separating local and remote printers in the machine management page
RemovalWarningRole = Qt.UserRole + 7
def __init__(self, parent = None) -> None: def __init__(self, parent = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -42,8 +43,9 @@ class GlobalStacksModel(ListModel):
CuraContainerRegistry.getInstance().containerRemoved.connect(self._onContainerChanged) CuraContainerRegistry.getInstance().containerRemoved.connect(self._onContainerChanged)
self._updateDelayed() self._updateDelayed()
## Handler for container added/removed events from registry
def _onContainerChanged(self, container) -> None: def _onContainerChanged(self, container) -> None:
"""Handler for container added/removed events from registry"""
# We only need to update when the added / removed container GlobalStack # We only need to update when the added / removed container GlobalStack
if isinstance(container, GlobalStack): if isinstance(container, GlobalStack):
self._updateDelayed() self._updateDelayed()
@ -65,13 +67,21 @@ class GlobalStacksModel(ListModel):
if parseBool(container_stack.getMetaDataEntry("hidden", False)): if parseBool(container_stack.getMetaDataEntry("hidden", False)):
continue 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) 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(), "id": container_stack.getId(),
"hasRemoteConnection": has_remote_connection, "hasRemoteConnection": has_remote_connection,
"metadata": container_stack.getMetaData().copy(), "metadata": container_stack.getMetaData().copy(),
"discoverySource": section_name}) "discoverySource": section_name,
items.sort(key = lambda i: (not i["hasRemoteConnection"], i["name"])) "removalWarning": removal_warning})
items.sort(key=lambda i: (not i["hasRemoteConnection"], i["name"]))
self.setItems(items) self.setItems(items)

View file

@ -4,13 +4,12 @@
import collections import collections
from PyQt5.QtCore import Qt, QTimer from PyQt5.QtCore import Qt, QTimer
from typing import TYPE_CHECKING, Optional, Dict from typing import TYPE_CHECKING, Optional, Dict
from cura.Machines.Models.IntentTranslations import intent_translations
from cura.Machines.Models.IntentModel import IntentModel from cura.Machines.Models.IntentModel import IntentModel
from cura.Settings.IntentManager import IntentManager from cura.Settings.IntentManager import IntentManager
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
from UM.Settings.ContainerRegistry import ContainerRegistry #To update the list if anything changes. 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 import cura.CuraApplication
if TYPE_CHECKING: if TYPE_CHECKING:
from UM.Settings.ContainerRegistry import ContainerInterface from UM.Settings.ContainerRegistry import ContainerInterface
@ -19,9 +18,9 @@ from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
## Lists the intent categories that are available for the current printer
# configuration.
class IntentCategoryModel(ListModel): class IntentCategoryModel(ListModel):
"""Lists the intent categories that are available for the current printer configuration. """
NameRole = Qt.UserRole + 1 NameRole = Qt.UserRole + 1
IntentCategoryRole = Qt.UserRole + 2 IntentCategoryRole = Qt.UserRole + 2
WeightRole = Qt.UserRole + 3 WeightRole = Qt.UserRole + 3
@ -32,10 +31,12 @@ class IntentCategoryModel(ListModel):
_translations = collections.OrderedDict() # type: "collections.OrderedDict[str,Dict[str,Optional[str]]]" _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 @classmethod
def _get_translations(cls): 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: if len(cls._translations) == 0:
cls._translations["default"] = { cls._translations["default"] = {
"name": catalog.i18nc("@label", "Default") "name": catalog.i18nc("@label", "Default")
@ -54,9 +55,12 @@ class IntentCategoryModel(ListModel):
} }
return cls._translations 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: 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__() super().__init__()
self._intent_category = intent_category self._intent_category = intent_category
@ -85,16 +89,18 @@ class IntentCategoryModel(ListModel):
self.update() self.update()
## Updates the list of intents if an intent profile was added or removed.
def _onContainerChange(self, container: "ContainerInterface") -> None: def _onContainerChange(self, container: "ContainerInterface") -> None:
"""Updates the list of intents if an intent profile was added or removed."""
if container.getMetaDataEntry("type") == "intent": if container.getMetaDataEntry("type") == "intent":
self.update() self.update()
def update(self): def update(self):
self._update_timer.start() self._update_timer.start()
## Updates the list of intents.
def _update(self) -> None: def _update(self) -> None:
"""Updates the list of intents."""
available_categories = IntentManager.getInstance().currentAvailableIntentCategories() available_categories = IntentManager.getInstance().currentAvailableIntentCategories()
result = [] result = []
for category in available_categories: for category in available_categories:
@ -110,9 +116,9 @@ class IntentCategoryModel(ListModel):
result.sort(key = lambda k: k["weight"]) result.sort(key = lambda k: k["weight"])
self.setItems(result) self.setItems(result)
## Get a display value for a category.
## for categories and keys
@staticmethod @staticmethod
def translation(category: str, key: str, default: Optional[str] = None): 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, {}) display_strings = IntentCategoryModel._get_translations().get(category, {})
return display_strings.get(key, default) return display_strings.get(key, default)

View file

@ -98,8 +98,9 @@ class IntentModel(ListModel):
new_items = sorted(new_items, key = lambda x: x["layer_height"]) new_items = sorted(new_items, key = lambda x: x["layer_height"])
self.setItems(new_items) self.setItems(new_items)
## Get the active materials for all extruders. No duplicates will be returned
def _getActiveMaterials(self) -> Set["MaterialNode"]: def _getActiveMaterials(self) -> Set["MaterialNode"]:
"""Get the active materials for all extruders. No duplicates will be returned"""
global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if global_stack is None: if global_stack is None:
return set() return set()

View file

@ -19,28 +19,31 @@ if TYPE_CHECKING:
catalog = i18nCatalog("cura") 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): class MaterialManagementModel(QObject):
## Triggered when a favorite is added or removed. """Proxy class to the materials page in the preferences.
# \param The base file of the material is provided as parameter when this
# emits. This class handles the actions in that page, such as creating new materials, renaming them, etc.
favoritesChanged = pyqtSignal(str) """
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) @pyqtSlot("QVariant", result = bool)
def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool: def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool:
"""Can a certain material be deleted, or is it still in use in one of the container stacks anywhere?
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() container_registry = CuraContainerRegistry.getInstance()
ids_to_remove = {metadata.get("id", "") for metadata in container_registry.findInstanceContainersMetadata(base_file = material_node.base_file)} 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"): for extruder_stack in container_registry.findContainerStacks(type = "extruder_train"):
@ -48,11 +51,14 @@ class MaterialManagementModel(QObject):
return False return False
return True 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) @pyqtSlot("QVariant", str)
def setMaterialName(self, material_node: "MaterialNode", name: str) -> None: 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() container_registry = CuraContainerRegistry.getInstance()
root_material_id = material_node.base_file root_material_id = material_node.base_file
if container_registry.isReadOnly(root_material_id): if container_registry.isReadOnly(root_material_id):
@ -60,18 +66,20 @@ class MaterialManagementModel(QObject):
return return
return container_registry.findContainers(id = root_material_id)[0].setName(name) 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") @pyqtSlot("QVariant")
def removeMaterial(self, material_node: "MaterialNode") -> None: 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() container_registry = CuraContainerRegistry.getInstance()
materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file) 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: for material_metadata in materials_this_base_file:
container_registry.removeContainer(material_metadata["id"]) 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, def duplicateMaterialByBaseFile(self, base_file: str, new_base_id: Optional[str] = None,
new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]: 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() container_registry = CuraContainerRegistry.getInstance()
root_materials = container_registry.findContainers(id = base_file) root_materials = container_registry.findContainers(id = base_file)
@ -171,29 +181,32 @@ class MaterialManagementModel(QObject):
return new_base_id 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) @pyqtSlot("QVariant", result = str)
def duplicateMaterial(self, material_node: "MaterialNode", new_base_id: Optional[str] = None, def duplicateMaterial(self, material_node: "MaterialNode", new_base_id: Optional[str] = None,
new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]: 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) 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) @pyqtSlot(result = str)
def createMaterial(self) -> 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. # Ensure all settings are saved.
application = cura.CuraApplication.CuraApplication.getInstance() application = cura.CuraApplication.CuraApplication.getInstance()
application.saveSettings() application.saveSettings()
@ -218,10 +231,13 @@ class MaterialManagementModel(QObject):
self.duplicateMaterial(preferred_material_node, new_base_id = new_id, new_metadata = new_metadata) self.duplicateMaterial(preferred_material_node, new_base_id = new_id, new_metadata = new_metadata)
return new_id 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) @pyqtSlot(str)
def addFavorite(self, material_base_file: str) -> None: 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() application = cura.CuraApplication.CuraApplication.getInstance()
favorites = application.getPreferences().getValue("cura/favorite_materials").split(";") favorites = application.getPreferences().getValue("cura/favorite_materials").split(";")
if material_base_file not in favorites: if material_base_file not in favorites:
@ -230,11 +246,13 @@ class MaterialManagementModel(QObject):
application.saveSettings() application.saveSettings()
self.favoritesChanged.emit(material_base_file) 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) @pyqtSlot(str)
def removeFavorite(self, material_base_file: str) -> None: 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() application = cura.CuraApplication.CuraApplication.getInstance()
favorites = application.getPreferences().getValue("cura/favorite_materials").split(";") favorites = application.getPreferences().getValue("cura/favorite_materials").split(";")
try: try:

View file

@ -9,11 +9,11 @@ from UM.Scene.Selection import Selection
from UM.Qt.ListModel import ListModel 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): 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() maxBuildPlateChanged = pyqtSignal()
activeBuildPlateChanged = pyqtSignal() activeBuildPlateChanged = pyqtSignal()
@ -39,9 +39,10 @@ class MultiBuildPlateModel(ListModel):
self._max_build_plate = max_build_plate self._max_build_plate = max_build_plate
self.maxBuildPlateChanged.emit() self.maxBuildPlateChanged.emit()
## Return the highest build plate number
@pyqtProperty(int, notify = maxBuildPlateChanged) @pyqtProperty(int, notify = maxBuildPlateChanged)
def maxBuildPlate(self): def maxBuildPlate(self):
"""Return the highest build plate number"""
return self._max_build_plate return self._max_build_plate
def setActiveBuildPlate(self, nr): def setActiveBuildPlate(self, nr):

View file

@ -26,10 +26,9 @@ if TYPE_CHECKING:
from cura.Settings.GlobalStack import GlobalStack from cura.Settings.GlobalStack import GlobalStack
#
# This the QML model for the quality management page.
#
class QualityManagementModel(ListModel): class QualityManagementModel(ListModel):
"""This the QML model for the quality management page."""
NameRole = Qt.UserRole + 1 NameRole = Qt.UserRole + 1
IsReadOnlyRole = Qt.UserRole + 2 IsReadOnlyRole = Qt.UserRole + 2
QualityGroupRole = Qt.UserRole + 3 QualityGroupRole = Qt.UserRole + 3
@ -74,11 +73,13 @@ class QualityManagementModel(ListModel):
def _onChange(self) -> None: def _onChange(self) -> None:
self._update_timer.start() 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) @pyqtSlot(QObject)
def removeQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup") -> None: 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)) Logger.log("i", "Removing quality changes group {group_name}".format(group_name = quality_changes_group.name))
removed_quality_changes_ids = set() removed_quality_changes_ids = set()
container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
@ -95,16 +96,19 @@ class QualityManagementModel(ListModel):
if extruder_stack.qualityChanges.getId() in removed_quality_changes_ids: if extruder_stack.qualityChanges.getId() in removed_quality_changes_ids:
extruder_stack.qualityChanges = empty_quality_changes_container 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) @pyqtSlot(QObject, str, result = str)
def renameQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup", new_name: str) -> 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)) 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: if new_name == quality_changes_group.name:
Logger.log("i", "QualityChangesGroup name {name} unchanged.".format(name = quality_changes_group.name)) Logger.log("i", "QualityChangesGroup name {name} unchanged.".format(name = quality_changes_group.name))
@ -138,13 +142,16 @@ class QualityManagementModel(ListModel):
return new_name 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") @pyqtSlot(str, "QVariantMap")
def duplicateQualityChanges(self, new_name: str, quality_model_item: Dict[str, Any]) -> None: 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() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if not global_stack: if not global_stack:
Logger.log("i", "No active global stack, cannot duplicate quality (changes) profile.") Logger.log("i", "No active global stack, cannot duplicate quality (changes) profile.")
@ -170,18 +177,18 @@ class QualityManagementModel(ListModel):
new_id = container_registry.uniqueName(container.getId()) new_id = container_registry.uniqueName(container.getId())
container_registry.addContainer(container.duplicate(new_id, new_name)) 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) @pyqtSlot(str)
def createQualityChanges(self, base_name: str) -> None: 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() machine_manager = cura.CuraApplication.CuraApplication.getInstance().getMachineManager()
global_stack = machine_manager.activeMachine global_stack = machine_manager.activeMachine
@ -201,7 +208,7 @@ class QualityManagementModel(ListModel):
# Go through the active stacks and create quality_changes containers from the user containers. # Go through the active stacks and create quality_changes containers from the user containers.
container_manager = ContainerManager.getInstance() 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: for stack in stack_list:
quality_container = stack.quality quality_container = stack.quality
quality_changes_container = stack.qualityChanges quality_changes_container = stack.qualityChanges
@ -220,14 +227,16 @@ class QualityManagementModel(ListModel):
container_registry.addContainer(new_changes) 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": 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() container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
base_id = machine.definition.getId() if extruder_stack is None else extruder_stack.getId() base_id = machine.definition.getId() if extruder_stack is None else extruder_stack.getId()
new_id = base_id + "_" + new_name new_id = base_id + "_" + new_name
@ -253,11 +262,13 @@ class QualityManagementModel(ListModel):
quality_changes.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.getInstance().SettingVersion) quality_changes.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.getInstance().SettingVersion)
return quality_changes 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: 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": if container.getMetaDataEntry("type") == "quality_changes":
self._update() self._update()
@ -322,6 +333,7 @@ class QualityManagementModel(ListModel):
"layer_height": layer_height, # layer_height is only used for sorting "layer_height": layer_height, # layer_height is only used for sorting
} }
item_list.append(item) item_list.append(item)
# Sort by layer_height for built-in qualities # Sort by layer_height for built-in qualities
item_list = sorted(item_list, key = lambda x: x["layer_height"]) item_list = sorted(item_list, key = lambda x: x["layer_height"])
@ -330,6 +342,9 @@ class QualityManagementModel(ListModel):
available_intent_list = [i for i in available_intent_list if i[0] != "default"] available_intent_list = [i for i in available_intent_list if i[0] != "default"]
result = [] result = []
for intent_category, quality_type in available_intent_list: for intent_category, quality_type in available_intent_list:
if not quality_group_dict[quality_type].is_available:
continue
result.append({ result.append({
"name": quality_group_dict[quality_type].name, # Use the quality name as the display name "name": quality_group_dict[quality_type].name, # Use the quality name as the display name
"is_read_only": True, "is_read_only": True,
@ -350,6 +365,9 @@ class QualityManagementModel(ListModel):
# CURA-6913 Note that custom qualities can be based on "not supported", so the quality group can be None. # 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_group = quality_group_dict.get(quality_changes_group.quality_type)
quality_type = 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, item = {"name": quality_changes_group.name,
"is_read_only": False, "is_read_only": False,
"quality_group": quality_group, "quality_group": quality_group,
@ -366,18 +384,19 @@ class QualityManagementModel(ListModel):
self.setItems(item_list) 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") @pyqtSlot(str, result = "QVariantList")
def getFileNameFilters(self, io_type): 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 from UM.i18n import i18nCatalog
catalog = i18nCatalog("uranium") catalog = i18nCatalog("uranium")
#TODO: This function should be in UM.Resources! #TODO: This function should be in UM.Resources!
@ -394,9 +413,11 @@ class QualityManagementModel(ListModel):
filters.append(catalog.i18nc("@item:inlistbox", "All Files (*)")) # Also allow arbitrary files, if the user so prefers. filters.append(catalog.i18nc("@item:inlistbox", "All Files (*)")) # Also allow arbitrary files, if the user so prefers.
return filters 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): 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 from UM.PluginRegistry import PluginRegistry
pr = PluginRegistry.getInstance() pr = PluginRegistry.getInstance()
active_plugin_ids = pr.getActivePlugins() active_plugin_ids = pr.getActivePlugins()

View file

@ -10,10 +10,9 @@ from cura.Machines.ContainerTree import ContainerTree
from cura.Machines.Models.MachineModelUtils import fetchLayerHeight 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): 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 NameRole = Qt.UserRole + 1
QualityTypeRole = Qt.UserRole + 2 QualityTypeRole = Qt.UserRole + 2
LayerHeightRole = Qt.UserRole + 3 LayerHeightRole = Qt.UserRole + 3

View file

@ -1,19 +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. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, Qt from PyQt5.QtCore import pyqtProperty, pyqtSignal, Qt
from typing import Set from typing import Set
import cura.CuraApplication import cura.CuraApplication
from UM import i18nCatalog
from UM.Logger import Logger from UM.Logger import Logger
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.ContainerRegistry import ContainerRegistry
import os
#
# This model is used to show details settings of the selected quality in the quality management page.
#
class QualitySettingsModel(ListModel): class QualitySettingsModel(ListModel):
"""This model is used to show details settings of the selected quality in the quality management page."""
KeyRole = Qt.UserRole + 1 KeyRole = Qt.UserRole + 1
LabelRole = Qt.UserRole + 2 LabelRole = Qt.UserRole + 2
UnitRole = Qt.UserRole + 3 UnitRole = Qt.UserRole + 3
@ -82,6 +84,12 @@ class QualitySettingsModel(ListModel):
global_container_stack = self._application.getGlobalContainerStack() global_container_stack = self._application.getGlobalContainerStack()
definition_container = global_container_stack.definition 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_group = self._selected_quality_item["quality_group"]
quality_changes_group = self._selected_quality_item["quality_changes_group"] quality_changes_group = self._selected_quality_item["quality_changes_group"]
@ -101,7 +109,8 @@ class QualitySettingsModel(ListModel):
# the settings in that quality_changes_group. # the settings in that quality_changes_group.
if quality_changes_group is not None: if quality_changes_group is not None:
container_registry = ContainerRegistry.getInstance() 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] 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_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()} extruders_container = {pos: None if not containers else containers[0] for pos, containers in extruders_containers.items()}
@ -152,7 +161,7 @@ class QualitySettingsModel(ListModel):
if self._selected_position == self.GLOBAL_STACK_POSITION: if self._selected_position == self.GLOBAL_STACK_POSITION:
user_value = global_container_stack.userChanges.getProperty(definition.key, "value") user_value = global_container_stack.userChanges.getProperty(definition.key, "value")
else: 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") user_value = extruder_stack.userChanges.getProperty(definition.key, "value")
if profile_value is None and user_value is None: if profile_value is None and user_value is None:

View file

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
import os import os
@ -7,6 +7,7 @@ from collections import OrderedDict
from PyQt5.QtCore import pyqtSlot, Qt from PyQt5.QtCore import pyqtSlot, Qt
from UM.Application import Application from UM.Application import Application
from UM.Logger import Logger
from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from UM.Settings.SettingFunction import SettingFunction 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. # Find the category of the instance by moving up until we find a category.
category = user_changes.getInstance(setting_key).definition category = user_changes.getInstance(setting_key).definition
while category.type != "category": while category is not None and category.type != "category":
category = category.parent category = category.parent
# Handle translation (and fallback if we weren't able to find any translation files. # Handle translation (and fallback if we weren't able to find any translation files.
if category is not None:
if self._i18n_catalog: if self._i18n_catalog:
category_label = self._i18n_catalog.i18nc(category.key + " label", category.label) category_label = self._i18n_catalog.i18nc(category.key + " label", category.label)
else: else:
category_label = category.label 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: if self._i18n_catalog:
label = self._i18n_catalog.i18nc(setting_key + " label", stack.getProperty(setting_key, "label")) label = self._i18n_catalog.i18nc(setting_key + " label", stack.getProperty(setting_key, "label"))

View file

@ -6,12 +6,12 @@ from typing import Any, Dict, Optional
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal 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): 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: def __init__(self, name: str, quality_type: str, intent_category: str, parent: Optional["QObject"] = None) -> None:
super().__init__(parent) super().__init__(parent)

View file

@ -3,36 +3,40 @@
from typing import Dict, Optional, List, Set from typing import Dict, Optional, List, Set
from PyQt5.QtCore import QObject, pyqtSlot
from UM.Logger import Logger from UM.Logger import Logger
from UM.Util import parseBool from UM.Util import parseBool
from cura.Machines.ContainerNode import ContainerNode 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: class QualityGroup:
## Constructs a new group. """A QualityGroup represents a group of quality containers that must be applied to each ContainerStack when it's
# \param name The user-visible name for the group. used.
# \param quality_type The quality level that each profile in this group
# has. 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: 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.name = name
self.node_for_global = None # type: Optional[ContainerNode] self.node_for_global = None # type: Optional[ContainerNode]
self.nodes_for_extruders = {} # type: Dict[int, ContainerNode] self.nodes_for_extruders = {} # type: Dict[int, ContainerNode]

View file

@ -13,12 +13,14 @@ if TYPE_CHECKING:
from cura.Machines.MachineNode import MachineNode 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): 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: def __init__(self, container_id: str, parent: Union["MaterialNode", "MachineNode"]) -> None:
super().__init__(container_id) super().__init__(container_id)
self.parent = parent self.parent = parent

View file

@ -17,16 +17,16 @@ if TYPE_CHECKING:
from cura.Machines.MachineNode import MachineNode 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): 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: def __init__(self, container_id: str, machine: "MachineNode") -> None:
super().__init__(container_id) super().__init__(container_id)
self.machine = machine self.machine = machine
@ -39,9 +39,10 @@ class VariantNode(ContainerNode):
container_registry.containerRemoved.connect(self._materialRemoved) container_registry.containerRemoved.connect(self._materialRemoved)
self._loadAll() self._loadAll()
## (Re)loads all materials under this variant.
@UM.FlameProfiler.profile @UM.FlameProfiler.profile
def _loadAll(self) -> None: def _loadAll(self) -> None:
"""(Re)loads all materials under this variant."""
container_registry = ContainerRegistry.getInstance() container_registry = ContainerRegistry.getInstance()
if not self.machine.has_materials: if not self.machine.has_materials:
@ -69,18 +70,18 @@ class VariantNode(ContainerNode):
if not self.materials: if not self.materials:
self.materials["empty_material"] = MaterialNode("empty_material", variant = self) 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: 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(): 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")): if self.machine.preferred_material == base_material and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")):
return material_node return material_node
@ -107,10 +108,10 @@ class VariantNode(ContainerNode):
)) ))
return fallback return fallback
## When a material gets added to the set of profiles, we need to update our
# tree here.
@UM.FlameProfiler.profile @UM.FlameProfiler.profile
def _materialAdded(self, container: ContainerInterface) -> None: 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": if container.getMetaDataEntry("type") != "material":
return # Not interested. return # Not interested.
if not ContainerRegistry.getInstance().findContainersMetadata(id = container.getId()): if not ContainerRegistry.getInstance().findContainersMetadata(id = container.getId()):

View file

@ -4,6 +4,8 @@
import copy import copy
from typing import List from typing import List
from PyQt5.QtCore import QCoreApplication
from UM.Job import Job from UM.Job import Job
from UM.Operations.GroupedOperation import GroupedOperation from UM.Operations.GroupedOperation import GroupedOperation
from UM.Message import Message from UM.Message import Message
@ -93,8 +95,9 @@ class MultiplyObjectsJob(Job):
nodes.append(new_node) nodes.append(new_node)
current_progress += 1 current_progress += 1
status_message.setProgress((current_progress / total_progress) * 100) status_message.setProgress((current_progress / total_progress) * 100)
QCoreApplication.processEvents()
Job.yieldThread() Job.yieldThread()
QCoreApplication.processEvents()
Job.yieldThread() Job.yieldThread()
if nodes: if nodes:

View file

@ -16,23 +16,27 @@ from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settin
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S" TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
## Class containing several helpers to deal with the authorization flow.
class AuthorizationHelpers: class AuthorizationHelpers:
"""Class containing several helpers to deal with the authorization flow."""
def __init__(self, settings: "OAuth2Settings") -> None: def __init__(self, settings: "OAuth2Settings") -> None:
self._settings = settings self._settings = settings
self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL) self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL)
@property @property
## The OAuth2 settings object.
def settings(self) -> "OAuth2Settings": def settings(self) -> "OAuth2Settings":
"""The OAuth2 settings object."""
return self._settings 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": 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 = { data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
"redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "",
@ -46,10 +50,13 @@ class AuthorizationHelpers:
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server") 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": 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.") Logger.log("d", "Refreshing the access token.")
data = { data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
@ -64,10 +71,13 @@ class AuthorizationHelpers:
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server") return AuthenticationResponse(success=False, err_message="Unable to connect to remote server")
@staticmethod @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": 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 token_data = None
try: try:
@ -89,10 +99,13 @@ class AuthorizationHelpers:
scope=token_data["scope"], scope=token_data["scope"],
received_at=datetime.now().strftime(TOKEN_TIMESTAMP_FORMAT)) 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"]: 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: try:
token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = { token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = {
"Authorization": "Bearer {}".format(access_token) "Authorization": "Bearer {}".format(access_token)
@ -115,16 +128,22 @@ class AuthorizationHelpers:
) )
@staticmethod @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: 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)) return "".join(random.choice("0123456789ABCDEF") for i in range(code_length))
@staticmethod @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: 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() encoded = sha512(verification_code.encode()).digest()
return b64encode(encoded, altchars = b"_-").decode() return b64encode(encoded, altchars = b"_-").decode()

View file

@ -14,9 +14,12 @@ if TYPE_CHECKING:
catalog = i18nCatalog("cura") 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): 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: def __init__(self, request, client_address, server) -> None:
super().__init__(request, client_address, server) 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. # 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) 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]]: 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") code = self._queryGet(query, "code")
state = self._queryGet(query, "state") state = self._queryGet(query, "state")
if state != self.state: if state != self.state:
@ -95,9 +101,10 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
self.authorization_helpers.settings.AUTH_FAILED_REDIRECT self.authorization_helpers.settings.AUTH_FAILED_REDIRECT
), token_response ), token_response
## Handle all other non-existing server calls.
@staticmethod @staticmethod
def _handleNotFound() -> ResponseData: 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.") 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: 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: def _sendData(self, data: bytes) -> None:
self.wfile.write(data) self.wfile.write(data)
## Convenience helper for getting values from a pre-parsed query string
@staticmethod @staticmethod
def _queryGet(query_data: Dict[Any, List], key: str, default: Optional[str] = None) -> Optional[str]: 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] return query_data.get(key, [default])[0]

View file

@ -2,6 +2,7 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from http.server import HTTPServer from http.server import HTTPServer
from socketserver import ThreadingMixIn
from typing import Callable, Any, TYPE_CHECKING from typing import Callable, Any, TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
@ -9,21 +10,26 @@ if TYPE_CHECKING:
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers
## The authorization request callback handler server. class AuthorizationRequestServer(ThreadingMixIn, HTTPServer):
# This subclass is needed to be able to pass some data to the request handler. """The authorization request callback handler server.
# This cannot be done on the request handler directly as the HTTPServer
# creates an instance of the handler after init. This subclass is needed to be able to pass some data to the request handler. This cannot be done on the request
class AuthorizationRequestServer(HTTPServer): handler directly as the HTTPServer creates an instance of the handler after init.
## Set the authorization helpers instance on the request handler. """
def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None: def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None:
"""Set the authorization helpers instance on the request handler."""
self.RequestHandlerClass.authorization_helpers = authorization_helpers # type: ignore 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: 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 self.RequestHandlerClass.authorization_callback = authorization_callback # type: ignore
## Set the verification code on the request handler.
def setVerificationCode(self, verification_code: str) -> None: def setVerificationCode(self, verification_code: str) -> None:
"""Set the verification code on the request handler."""
self.RequestHandlerClass.verification_code = verification_code # type: ignore self.RequestHandlerClass.verification_code = verification_code # type: ignore
def setState(self, state: str) -> None: def setState(self, state: str) -> None:

View file

@ -3,33 +3,34 @@
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING, Dict
from urllib.parse import urlencode from urllib.parse import urlencode, quote_plus
import requests.exceptions import requests.exceptions
from PyQt5.QtCore import QUrl from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QDesktopServices from PyQt5.QtGui import QDesktopServices
from UM.Logger import Logger from UM.Logger import Logger
from UM.Message import Message from UM.Message import Message
from UM.Signal import Signal from UM.Signal import Signal
from UM.i18n import i18nCatalog
from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT
from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
from cura.OAuth2.Models import AuthenticationResponse from cura.OAuth2.Models import AuthenticationResponse
from UM.i18n import i18nCatalog
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")
if TYPE_CHECKING: if TYPE_CHECKING:
from cura.OAuth2.Models import UserProfile, OAuth2Settings from cura.OAuth2.Models import UserProfile, OAuth2Settings
from UM.Preferences import Preferences from UM.Preferences import Preferences
MYCLOUD_LOGOFF_URL = "https://mycloud.ultimaker.com/logoff"
## The authorization service is responsible for handling the login flow,
# storing user credentials and providing account information.
class AuthorizationService: 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. # Emit signal when authentication is completed.
onAuthStateChanged = Signal() onAuthStateChanged = Signal()
@ -61,11 +62,16 @@ class AuthorizationService:
if self._preferences: if self._preferences:
self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}")
## 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"]: 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 not self._user_profile:
# If no user profile was stored locally, we try to get it from JWT. # If no user profile was stored locally, we try to get it from JWT.
try: try:
@ -83,9 +89,12 @@ class AuthorizationService:
return self._user_profile 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"]: 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 not self._auth_data or self._auth_data.access_token is None:
# If no auth data exists, we should always log in again. # If no auth data exists, we should always log in again.
Logger.log("d", "There was no auth data or access token") Logger.log("d", "There was no auth data or access token")
@ -108,8 +117,9 @@ class AuthorizationService:
self._storeAuthData(self._auth_data) self._storeAuthData(self._auth_data)
return self._auth_helpers.parseJWT(self._auth_data.access_token) 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]: def getAccessToken(self) -> Optional[str]:
"""Get the access token as provided by the repsonse data."""
if self._auth_data is None: if self._auth_data is None:
Logger.log("d", "No auth data to retrieve the access_token from") Logger.log("d", "No auth data to retrieve the access_token from")
return None return None
@ -124,8 +134,9 @@ class AuthorizationService:
return self._auth_data.access_token if self._auth_data else None 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: 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: if self._auth_data is None or self._auth_data.refresh_token is None:
Logger.log("w", "Unable to refresh access token, since there is no refresh token.") Logger.log("w", "Unable to refresh access token, since there is no refresh token.")
return return
@ -137,14 +148,16 @@ class AuthorizationService:
Logger.log("w", "Failed to get a new access token from the server.") Logger.log("w", "Failed to get a new access token from the server.")
self.onAuthStateChanged.emit(logged_in = False) self.onAuthStateChanged.emit(logged_in = False)
## Delete the authentication data that we have stored locally (eg; logout)
def deleteAuthData(self) -> None: def deleteAuthData(self) -> None:
"""Delete the authentication data that we have stored locally (eg; logout)"""
if self._auth_data is not None: if self._auth_data is not None:
self._storeAuthData() self._storeAuthData()
self.onAuthStateChanged.emit(logged_in = False) 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, force_browser_logout: bool = False) -> None:
def startAuthorizationFlow(self) -> 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...") Logger.log("d", "Starting new OAuth2 flow...")
# Create the tokens needed for the code challenge (PKCE) extension for OAuth2. # Create the tokens needed for the code challenge (PKCE) extension for OAuth2.
@ -155,8 +168,8 @@ class AuthorizationService:
state = AuthorizationHelpers.generateVerificationCode() state = AuthorizationHelpers.generateVerificationCode()
# Create the query string needed for the OAuth2 flow. # Create the query dict needed for the OAuth2 flow.
query_string = urlencode({ query_parameters_dict = {
"client_id": self._settings.CLIENT_ID, "client_id": self._settings.CLIENT_ID,
"redirect_uri": self._settings.CALLBACK_URL, "redirect_uri": self._settings.CALLBACK_URL,
"scope": self._settings.CLIENT_SCOPES, "scope": self._settings.CLIENT_SCOPES,
@ -164,16 +177,43 @@ class AuthorizationService:
"state": state, # Forever in our Hearts, RIP "(.Y.)" (2018-2020) "state": state, # Forever in our Hearts, RIP "(.Y.)" (2018-2020)
"code_challenge": challenge_code, "code_challenge": challenge_code,
"code_challenge_method": "S512" "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. # Start a local web server to receive the callback URL on.
try:
self._server.start(verification_code, state) 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: def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:
"""Callback method for the authentication flow."""
if auth_response.success: if auth_response.success:
self._storeAuthData(auth_response) self._storeAuthData(auth_response)
self.onAuthStateChanged.emit(logged_in = True) 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.onAuthenticationError.emit(logged_in = False, error_message = auth_response.err_message)
self._server.stop() # Stop the web server at all times. self._server.stop() # Stop the web server at all times.
## Load authentication data from preferences.
def loadAuthDataFromPreferences(self) -> None: def loadAuthDataFromPreferences(self) -> None:
"""Load authentication data from preferences."""
if self._preferences is None: if self._preferences is None:
Logger.log("e", "Unable to load authentication data, since no preference has been set!") Logger.log("e", "Unable to load authentication data, since no preference has been set!")
return return
@ -203,8 +244,9 @@ class AuthorizationService:
except ValueError: except ValueError:
Logger.logException("w", "Could not load auth data from preferences") Logger.logException("w", "Could not load auth data from preferences")
## Store authentication data in preferences.
def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None: def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None:
"""Store authentication data in preferences."""
Logger.log("d", "Attempting to store the auth data") Logger.log("d", "Attempting to store the auth data")
if self._preferences is None: if self._preferences is None:
Logger.log("e", "Unable to save authentication data, since no preference has been set!") Logger.log("e", "Unable to save authentication data, since no preference has been set!")

View file

@ -1,6 +1,6 @@
# Copyright (c) 2020 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import sys
import threading import threading
from typing import Any, Callable, Optional, TYPE_CHECKING from typing import Any, Callable, Optional, TYPE_CHECKING
@ -20,18 +20,23 @@ if TYPE_CHECKING:
class LocalAuthorizationServer: 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", def __init__(self, auth_helpers: "AuthorizationHelpers",
auth_state_changed_callback: Callable[["AuthenticationResponse"], Any], auth_state_changed_callback: Callable[["AuthenticationResponse"], Any],
daemon: bool) -> None: 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 = None # type: Optional[AuthorizationRequestServer]
self._web_server_thread = None # type: Optional[threading.Thread] self._web_server_thread = None # type: Optional[threading.Thread]
self._web_server_port = auth_helpers.settings.CALLBACK_PORT 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._auth_state_changed_callback = auth_state_changed_callback
self._daemon = daemon 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: 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 self._web_server:
# If the server is already running (because of a previously aborted auth flow), we don't have to start it. # 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. # We still inject the new verification code though.
@ -63,18 +71,37 @@ class LocalAuthorizationServer:
self._web_server.setState(state) self._web_server.setState(state)
# Start the server on a new thread. # 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() self._web_server_thread.start()
## Stops the web server if it was running. It also does some cleanup.
def stop(self) -> None: 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...") Logger.log("d", "Stopping local oauth2 web server...")
if self._web_server: if self._web_server:
try: try:
self._web_server.server_close() self._web_server.shutdown()
except OSError: except OSError:
# OS error can happen if the socket was already closed. We really don't care about that case. # OS error can happen if the socket was already closed. We really don't care about that case.
pass pass
self._web_server = None self._web_server = None
self._web_server_thread = 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()

View file

@ -8,8 +8,9 @@ class BaseModel:
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
## OAuth OAuth2Settings data template.
class OAuth2Settings(BaseModel): class OAuth2Settings(BaseModel):
"""OAuth OAuth2Settings data template."""
CALLBACK_PORT = None # type: Optional[int] CALLBACK_PORT = None # type: Optional[int]
OAUTH_SERVER_URL = None # type: Optional[str] OAUTH_SERVER_URL = None # type: Optional[str]
CLIENT_ID = None # type: Optional[str] CLIENT_ID = None # type: Optional[str]
@ -20,16 +21,18 @@ class OAuth2Settings(BaseModel):
AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str
## User profile data template.
class UserProfile(BaseModel): class UserProfile(BaseModel):
"""User profile data template."""
user_id = None # type: Optional[str] user_id = None # type: Optional[str]
username = None # type: Optional[str] username = None # type: Optional[str]
profile_image_url = None # type: Optional[str] profile_image_url = None # type: Optional[str]
## Authentication data template.
class AuthenticationResponse(BaseModel): 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 success = True # type: bool
token_type = None # type: Optional[str] token_type = None # type: Optional[str]
access_token = None # type: Optional[str] access_token = None # type: Optional[str]
@ -40,22 +43,25 @@ class AuthenticationResponse(BaseModel):
received_at = None # type: Optional[str] received_at = None # type: Optional[str]
## Response status template.
class ResponseStatus(BaseModel): class ResponseStatus(BaseModel):
"""Response status template."""
code = 200 # type: int code = 200 # type: int
message = "" # type: str message = "" # type: str
## Response data template.
class ResponseData(BaseModel): class ResponseData(BaseModel):
"""Response data template."""
status = None # type: ResponseStatus status = None # type: ResponseStatus
data_stream = None # type: Optional[bytes] data_stream = None # type: Optional[bytes]
redirect_uri = None # type: Optional[str] redirect_uri = None # type: Optional[str]
content_type = "text/html" # type: str content_type = "text/html" # type: str
## Possible HTTP responses.
HTTP_STATUS = { HTTP_STATUS = {
"""Possible HTTP responses."""
"OK": ResponseStatus(code = 200, message = "OK"), "OK": ResponseStatus(code = 200, message = "OK"),
"NOT_FOUND": ResponseStatus(code = 404, message = "NOT FOUND"), "NOT_FOUND": ResponseStatus(code = 404, message = "NOT FOUND"),
"REDIRECT": ResponseStatus(code = 302, message = "REDIRECT") "REDIRECT": ResponseStatus(code = 302, message = "REDIRECT")

View file

@ -7,18 +7,21 @@ from UM.Scene.Iterator import Iterator
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from functools import cmp_to_key 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): 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: def __init__(self, scene_node) -> None:
super().__init__(scene_node) # Call super to make multiple inheritance work. 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._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. 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: def _fillStack(self) -> None:
"""Fills the ``_node_stack`` with a list of scene nodes that need to be printed in order. """
node_list = [] node_list = []
for node in self._scene_node.getChildren(): for node in self._scene_node.getChildren():
if not issubclass(type(node), SceneNode): if not issubclass(type(node), SceneNode):
@ -75,10 +78,14 @@ class OneAtATimeIterator(Iterator.Iterator):
return True return True
return False 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: 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) node_index = self._original_node_list.index(node)
for other_node in other_nodes: for other_node in other_nodes:
other_node_index = self._original_node_list.index(other_node) other_node_index = self._original_node_list.index(other_node)
@ -86,14 +93,26 @@ class OneAtATimeIterator(Iterator.Iterator):
return True return True
return False return False
## Calculate score simply sums the number of other objects it 'blocks'
def _calculateScore(self, a: SceneNode, b: SceneNode) -> int: 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_a = sum(self._hit_map[self._original_node_list.index(a)])
score_b = sum(self._hit_map[self._original_node_list.index(b)]) score_b = sum(self._hit_map[self._original_node_list.index(b)])
return score_a - score_b return score_a - score_b
## Checks if A can be printed before B
def _checkHit(self, a: SceneNode, b: SceneNode) -> bool: 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: if a == b:
return False return False
@ -116,12 +135,14 @@ class OneAtATimeIterator(Iterator.Iterator):
return False return False
## Internal object used to keep track of a possible order in which to print objects.
class _ObjectOrder: class _ObjectOrder:
## Creates the _ObjectOrder instance. """Internal object used to keep track of a possible order in which to print objects."""
# \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.
def __init__(self, order: List[SceneNode], todo: List[SceneNode]) -> None: 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.order = order
self.todo = todo self.todo = todo

View file

@ -6,8 +6,9 @@ from UM.Operations.GroupedOperation import GroupedOperation
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
## A specialised operation designed specifically to modify the previous operation.
class PlatformPhysicsOperation(Operation): class PlatformPhysicsOperation(Operation):
"""A specialised operation designed specifically to modify the previous operation."""
def __init__(self, node: SceneNode, translation: Vector) -> None: def __init__(self, node: SceneNode, translation: Vector) -> None:
super().__init__() super().__init__()
self._node = node self._node = node

View file

@ -7,8 +7,9 @@ from UM.Operations.Operation import Operation
from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator
## Simple operation to set the buildplate number of a scenenode.
class SetBuildPlateNumberOperation(Operation): class SetBuildPlateNumberOperation(Operation):
"""Simple operation to set the buildplate number of a scenenode."""
def __init__(self, node: SceneNode, build_plate_nr: int) -> None: def __init__(self, node: SceneNode, build_plate_nr: int) -> None:
super().__init__() super().__init__()
self._node = node self._node = node

View file

@ -5,33 +5,38 @@ from typing import Optional
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Operations import Operation 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): class SetParentOperation(Operation.Operation):
## Initialises this SetParentOperation. """An operation that parents a scene node to another scene node."""
#
# \param node The node which will be reparented.
# \param parent_node The node which will be the parent.
def __init__(self, node: SceneNode, parent_node: Optional[SceneNode]) -> None: 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__() super().__init__()
self._node = node self._node = node
self._parent = parent_node self._parent = parent_node
self._old_parent = node.getParent() # To restore the previous parent in case of an undo. 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: def undo(self) -> None:
"""Undoes the set-parent operation, restoring the old parent."""
self._set_parent(self._old_parent) self._set_parent(self._old_parent)
## Re-applies the set-parent operation.
def redo(self) -> None: def redo(self) -> None:
"""Re-applies the set-parent operation."""
self._set_parent(self._parent) 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: 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: if new_parent:
current_parent = self._node.getParent() current_parent = self._node.getParent()
if current_parent: if current_parent:
@ -57,8 +62,10 @@ class SetParentOperation(Operation.Operation):
self._node.setParent(new_parent) 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: 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) return "SetParentOperation(node = {0}, parent_node={1})".format(self._node, self._parent)

View file

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from UM.Qt.QtApplication import QtApplication from UM.Qt.QtApplication import QtApplication
from UM.Logger import Logger
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
from UM.Resources import Resources from UM.Resources import Resources
from UM.View.RenderPass import RenderPass from UM.View.RenderPass import RenderPass
from UM.View.GL.OpenGL import OpenGL from UM.View.GL.OpenGL import OpenGL
from UM.View.GL.ShaderProgram import InvalidShaderProgramError
from UM.View.RenderBatch import RenderBatch from UM.View.RenderBatch import RenderBatch
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
@ -16,11 +18,15 @@ from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
if TYPE_CHECKING: if TYPE_CHECKING:
from UM.View.GL.ShaderProgram import ShaderProgram 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): 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: def __init__(self, width: int, height: int) -> None:
super().__init__("picking", width, height) super().__init__("picking", width, height)
@ -31,7 +37,11 @@ class PickingPass(RenderPass):
def render(self) -> None: def render(self) -> None:
if not self._shader: if not self._shader:
try:
self._shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "camera_distance.shader")) 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() width, height = self.getSize()
self._gl.glViewport(0, 0, width, height) self._gl.glViewport(0, 0, width, height)
@ -44,14 +54,20 @@ class PickingPass(RenderPass):
# Fill up the batch with objects that can be sliced. ` # 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. 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(): 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() self.bind()
batch.render(self._scene.getActiveCamera()) batch.render(self._scene.getActiveCamera())
self.release() self.release()
## Get the distance in mm from the camera to at a certain pixel coordinate.
def getPickedDepth(self, x: int, y: int) -> float: 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() output = self.getOutput()
window_size = self._renderer.getWindowSize() window_size = self._renderer.getWindowSize()
@ -66,8 +82,14 @@ class PickingPass(RenderPass):
distance = (distance & 0x00ffffff) / 1000. # drop the alpha channel and covert to mm distance = (distance & 0x00ffffff) / 1000. # drop the alpha channel and covert to mm
return distance return distance
## Get the world coordinates of a picked point
def getPickedPosition(self, x: int, y: int) -> Vector: 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) distance = self.getPickedDepth(x, y)
camera = self._scene.getActiveCamera() camera = self._scene.getActiveCamera()
if camera: if camera:

View file

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import QTimer from PyQt5.QtCore import QTimer
from shapely.errors import TopologicalError # To capture errors if Shapely messes up.
from UM.Application import Application from UM.Application import Application
from UM.Logger import Logger
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
@ -136,7 +138,11 @@ class PlatformPhysics:
own_convex_hull = node.callDecoration("getConvexHull") own_convex_hull = node.callDecoration("getConvexHull")
other_convex_hull = other_node.callDecoration("getConvexHull") other_convex_hull = other_node.callDecoration("getConvexHull")
if own_convex_hull and other_convex_hull: if own_convex_hull and other_convex_hull:
try:
overlap = own_convex_hull.translate(move_vector.x, move_vector.z).intersectsPolygon(other_convex_hull) overlap = own_convex_hull.translate(move_vector.x, move_vector.z).intersectsPolygon(other_convex_hull)
except TopologicalError as e: # Can happen if the convex hull is degenerate?
Logger.warning("Got a topological error when calculating convex hull intersection: {err}".format(err = str(e)))
overlap = False
if overlap: # Moving ensured that overlap was still there. Try anew! if overlap: # Moving ensured that overlap was still there. Try anew!
temp_move_vector = move_vector.set(x = move_vector.x + overlap[0] * self._move_factor, temp_move_vector = move_vector.set(x = move_vector.x + overlap[0] * self._move_factor,
z = move_vector.z + overlap[1] * self._move_factor) z = move_vector.z + overlap[1] * self._move_factor)
@ -175,7 +181,7 @@ class PlatformPhysics:
if tool.getPluginId() == "TranslateTool": if tool.getPluginId() == "TranslateTool":
for node in Selection.getAllSelectedObjects(): for node in Selection.getAllSelectedObjects():
if node.getBoundingBox().bottom < 0: if node.getBoundingBox() and node.getBoundingBox().bottom < 0:
if not node.getDecorator(ZOffsetDecorator.ZOffsetDecorator): if not node.getDecorator(ZOffsetDecorator.ZOffsetDecorator):
node.addDecorator(ZOffsetDecorator.ZOffsetDecorator()) node.addDecorator(ZOffsetDecorator.ZOffsetDecorator())

View file

@ -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. # 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.Application import Application
from UM.Logger import Logger
from UM.Resources import Resources from UM.Resources import Resources
from UM.View.RenderPass import RenderPass from UM.View.RenderPass import RenderPass
@ -20,9 +21,14 @@ if TYPE_CHECKING:
from UM.Scene.Camera import Camera from UM.Scene.Camera import Camera
# Make color brighter by normalizing it (maximum factor 2.5 brighter) def prettier_color(color_list: List[float]) -> List[float]:
# color_list is a list of 4 elements: [r, g, b, a], each element is a float 0..1 """Make color brighter by normalizing
def prettier_color(color_list):
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]) maximum = max(color_list[:3])
if maximum > 0: if maximum > 0:
factor = min(1 / maximum, 2.5) 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] 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): 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: def __init__(self, width: int, height: int) -> None:
super().__init__("preview", width, height, 0) 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_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_specularColor", [0.6, 0.6, 0.6, 1.0])
self._shader.setUniformValue("u_shininess", 20.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. 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: if not self._non_printing_shader:
if self._non_printing_shader:
self._non_printing_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader")) self._non_printing_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader"))
if self._non_printing_shader:
self._non_printing_shader.setUniformValue("u_diffuseColor", [0.5, 0.5, 0.5, 0.5]) self._non_printing_shader.setUniformValue("u_diffuseColor", [0.5, 0.5, 0.5, 0.5])
self._non_printing_shader.setUniformValue("u_opacity", 0.6) self._non_printing_shader.setUniformValue("u_opacity", 0.6)
@ -102,12 +114,12 @@ class PreviewPass(RenderPass):
1.0] 1.0]
uniforms["diffuse_color"] = prettier_color(diffuse_color) uniforms["diffuse_color"] = prettier_color(diffuse_color)
uniforms["diffuse_color_2"] = diffuse_color2 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: else:
# Normal scene node # Normal scene node
uniforms = {} uniforms = {}
uniforms["diffuse_color"] = prettier_color(cast(CuraSceneNode, node).getDiffuseColor()) 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() self.bind()

View file

@ -10,8 +10,14 @@ class PrintJobPreviewImageProvider(QQuickImageProvider):
def __init__(self): def __init__(self):
super().__init__(QQuickImageProvider.Image) super().__init__(QQuickImageProvider.Image)
## Request a new image.
def requestImage(self, id: str, size: QSize) -> Tuple[QImage, QSize]: 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 # 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. # increment, we need to strip that first.
uuid = id[id.find("/") + 1:] uuid = id[id.find("/") + 1:]

View file

@ -7,6 +7,8 @@ from enum import IntEnum
from threading import Thread from threading import Thread
from typing import Union from typing import Union
from UM.Logger import Logger
MYPY = False MYPY = False
if MYPY: if MYPY:
from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
@ -33,15 +35,22 @@ class FirmwareUpdater(QObject):
else: else:
self._firmware_file = firmware_file self._firmware_file = firmware_file
self._setFirmwareUpdateState(FirmwareUpdateState.updating) if self._firmware_file == "":
self._setFirmwareUpdateState(FirmwareUpdateState.firmware_not_found_error)
return
self._setFirmwareUpdateState(FirmwareUpdateState.updating)
try:
self._update_firmware_thread.start() self._update_firmware_thread.start()
except RuntimeError:
Logger.warning("Could not start the update thread, since it's still running!")
def _updateFirmware(self) -> None: def _updateFirmware(self) -> None:
raise NotImplementedError("_updateFirmware needs to be implemented") raise NotImplementedError("_updateFirmware needs to be implemented")
## Cleanup after a succesful update
def _cleanupAfterUpdate(self) -> None: def _cleanupAfterUpdate(self) -> None:
"""Cleanup after a succesful update"""
# Clean up for next attempt. # Clean up for next attempt.
self._update_firmware_thread = Thread(target=self._updateFirmware, daemon=True, name = "FirmwareUpdateThread") self._update_firmware_thread = Thread(target=self._updateFirmware, daemon=True, name = "FirmwareUpdateThread")
self._firmware_file = "" self._firmware_file = ""

View file

@ -47,10 +47,13 @@ class ExtruderConfigurationModel(QObject):
def hotendID(self) -> Optional[str]: def hotendID(self) -> Optional[str]:
return self._hotend_id 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: 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 return True
def __str__(self) -> str: def __str__(self) -> str:
@ -71,11 +74,11 @@ class ExtruderConfigurationModel(QObject):
# Empty materials should be ignored for comparison # Empty materials should be ignored for comparison
if self.activeMaterial is not None and other.activeMaterial is not None: if self.activeMaterial is not None and other.activeMaterial is not None:
if self.activeMaterial.guid != other.activeMaterial.guid: if self.activeMaterial.guid != other.activeMaterial.guid:
if self.activeMaterial.guid != "" and other.activeMaterial.guid != "": if self.activeMaterial.guid == "" and other.activeMaterial.guid == "":
return False
else:
# At this point there is no material, so it doesn't matter what the hotend is. # At this point there is no material, so it doesn't matter what the hotend is.
return True return True
else:
return False
if self.hotendID != other.hotendID: if self.hotendID != other.hotendID:
return False return False

View file

@ -54,8 +54,9 @@ class ExtruderOutputModel(QObject):
def updateActiveMaterial(self, material: Optional["MaterialOutputModel"]) -> None: def updateActiveMaterial(self, material: Optional["MaterialOutputModel"]) -> None:
self._extruder_configuration.setMaterial(material) self._extruder_configuration.setMaterial(material)
## Update the hotend temperature. This only changes it locally.
def updateHotendTemperature(self, temperature: float) -> None: def updateHotendTemperature(self, temperature: float) -> None:
"""Update the hotend temperature. This only changes it locally."""
if self._hotend_temperature != temperature: if self._hotend_temperature != temperature:
self._hotend_temperature = temperature self._hotend_temperature = temperature
self.hotendTemperatureChanged.emit() self.hotendTemperatureChanged.emit()
@ -65,9 +66,10 @@ class ExtruderOutputModel(QObject):
self._target_hotend_temperature = temperature self._target_hotend_temperature = temperature
self.targetHotendTemperatureChanged.emit() self.targetHotendTemperatureChanged.emit()
## Set the target hotend temperature. This ensures that it's actually sent to the remote.
@pyqtSlot(float) @pyqtSlot(float)
def setTargetHotendTemperature(self, temperature: float) -> None: 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._printer.getController().setTargetHotendTemperature(self._printer, self, temperature)
self.updateTargetHotendTemperature(temperature) self.updateTargetHotendTemperature(temperature)
@ -97,17 +99,19 @@ class ExtruderOutputModel(QObject):
self._is_preheating = pre_heating self._is_preheating = pre_heating
self.isPreheatingChanged.emit() self.isPreheatingChanged.emit()
@pyqtProperty(bool, notify=isPreheatingChanged) @pyqtProperty(bool, notify = isPreheatingChanged)
def isPreheating(self) -> bool: def isPreheating(self) -> bool:
return self._is_preheating 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) @pyqtSlot(float, float)
def preheatHotend(self, temperature: float, duration: float) -> None: 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) self._printer._controller.preheatHotend(self, temperature, duration)
@pyqtSlot() @pyqtSlot()

View file

@ -48,9 +48,11 @@ class PrinterConfigurationModel(QObject):
def buildplateConfiguration(self) -> str: def buildplateConfiguration(self) -> str:
return self._buildplate_configuration 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: 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: if not self._extruder_configurations:
return False return False
for configuration in self._extruder_configurations: for configuration in self._extruder_configurations:
@ -97,9 +99,11 @@ class PrinterConfigurationModel(QObject):
return True 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): 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) extruder_hash = hash(0)
first_extruder = None first_extruder = None
for configuration in self._extruder_configurations: for configuration in self._extruder_configurations:

View file

@ -163,13 +163,15 @@ class PrinterOutputModel(QObject):
def moveHead(self, x: float = 0, y: float = 0, z: float = 0, speed: float = 3000) -> None: def moveHead(self, x: float = 0, y: float = 0, z: float = 0, speed: float = 3000) -> None:
self._controller.moveHead(self, x, y, z, speed) 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) @pyqtSlot(float, float)
def preheatBed(self, temperature: float, duration: float) -> None: 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) self._controller.preheatBed(self, temperature, duration)
@pyqtSlot() @pyqtSlot()
@ -200,8 +202,9 @@ class PrinterOutputModel(QObject):
self._unique_name = unique_name self._unique_name = unique_name
self.nameChanged.emit() self.nameChanged.emit()
## Update the bed temperature. This only changes it locally.
def updateBedTemperature(self, temperature: float) -> None: def updateBedTemperature(self, temperature: float) -> None:
"""Update the bed temperature. This only changes it locally."""
if self._bed_temperature != temperature: if self._bed_temperature != temperature:
self._bed_temperature = temperature self._bed_temperature = temperature
self.bedTemperatureChanged.emit() self.bedTemperatureChanged.emit()
@ -211,9 +214,10 @@ class PrinterOutputModel(QObject):
self._target_bed_temperature = temperature self._target_bed_temperature = temperature
self.targetBedTemperatureChanged.emit() self.targetBedTemperatureChanged.emit()
## Set the target bed temperature. This ensures that it's actually sent to the remote.
@pyqtSlot(float) @pyqtSlot(float)
def setTargetBedTemperature(self, temperature: float) -> None: 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._controller.setTargetBedTemperature(self, temperature)
self.updateTargetBedTemperature(temperature) self.updateTargetBedTemperature(temperature)

View file

@ -32,8 +32,9 @@ class NetworkMJPGImage(QQuickPaintedItem):
self.setAntialiasing(True) self.setAntialiasing(True)
## Ensure that close gets called when object is destroyed
def __del__(self) -> None: def __del__(self) -> None:
"""Ensure that close gets called when object is destroyed"""
self.stop() self.stop()
@ -111,7 +112,7 @@ class NetworkMJPGImage(QQuickPaintedItem):
if not self._image_reply.isFinished(): if not self._image_reply.isFinished():
self._image_reply.close() self._image_reply.close()
except Exception as e: # RuntimeError except Exception: # RuntimeError
pass # It can happen that the wrapped c++ object is already deleted. pass # It can happen that the wrapped c++ object is already deleted.
self._image_reply = None self._image_reply = None

View file

@ -84,8 +84,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
def _compressGCode(self) -> Optional[bytes]: def _compressGCode(self) -> Optional[bytes]:
self._compressing_gcode = True self._compressing_gcode = True
## Mash the data into single string
max_chars_per_line = int(1024 * 1024 / 4) # 1/4 MB per line. max_chars_per_line = int(1024 * 1024 / 4) # 1/4 MB per line.
"""Mash the data into single string"""
file_data_bytes_list = [] file_data_bytes_list = []
batched_lines = [] batched_lines = []
batched_lines_count = 0 batched_lines_count = 0
@ -145,9 +145,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent)
return request 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: 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) return self._createFormPart(content_header, data, content_type)
def _createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: def _createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
@ -163,8 +165,9 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
part.setBody(data) part.setBody(data)
return part return part
## Convenience function to get the username, either from the cloud or from the OS.
def _getUserName(self) -> str: 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 # check first if we are logged in with the Ultimaker Account
account = CuraApplication.getInstance().getCuraAPI().account # type: Account account = CuraApplication.getInstance().getCuraAPI().account # type: Account
if account and account.isLoggedIn: if account and account.isLoggedIn:
@ -187,15 +190,17 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
self._createNetworkManager() self._createNetworkManager()
assert (self._manager is not None) 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", def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = "application/json",
on_finished: Optional[Callable[[QNetworkReply], None]] = None, on_finished: Optional[Callable[[QNetworkReply], None]] = None,
on_progress: Optional[Callable[[int, int], None]] = 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() self._validateManager()
request = self._createEmptyRequest(url, content_type = content_type) request = self._createEmptyRequest(url, content_type = content_type)
@ -212,10 +217,12 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
if on_progress is not None: if on_progress is not None:
reply.uploadProgress.connect(on_progress) 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: 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() self._validateManager()
request = self._createEmptyRequest(url) request = self._createEmptyRequest(url)
@ -228,10 +235,12 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
reply = self._manager.deleteResource(request) reply = self._manager.deleteResource(request)
self._registerOnFinishedCallback(reply, on_finished) 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: 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() self._validateManager()
request = self._createEmptyRequest(url) request = self._createEmptyRequest(url)
@ -244,14 +253,18 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
reply = self._manager.get(request) reply = self._manager.get(request)
self._registerOnFinishedCallback(reply, on_finished) 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], def post(self, url: str, data: Union[str, bytes],
on_finished: Optional[Callable[[QNetworkReply], None]], on_finished: Optional[Callable[[QNetworkReply], None]],
on_progress: Optional[Callable[[int, int], None]] = None) -> 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() self._validateManager()
request = self._createEmptyRequest(url) request = self._createEmptyRequest(url)
@ -318,10 +331,13 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
if on_finished is not None: if on_finished is not None:
self._onFinishedCallbacks[reply.url().toString() + str(reply.operation())] = on_finished 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: 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() global_container_stack = CuraApplication.getInstance().getGlobalContainerStack()
active_machine_network_name = CuraApplication.getInstance().getMachineManager().activeMachineNetworkKey() active_machine_network_name = CuraApplication.getInstance().getMachineManager().activeMachineNetworkKey()
if global_container_stack and device_id == active_machine_network_name: if global_container_stack and device_id == active_machine_network_name:
@ -366,32 +382,38 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
def getProperties(self): def getProperties(self):
return self._properties return self._properties
## Get the unique key of this machine
# \return key String containing the key of the machine.
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def key(self) -> str: def key(self) -> str:
"""Get the unique key of this machine
:return: key String containing the key of the machine.
"""
return self._id return self._id
## The IP address of the printer.
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def address(self) -> str: def address(self) -> str:
"""The IP address of the printer."""
return self._properties.get(b"address", b"").decode("utf-8") return self._properties.get(b"address", b"").decode("utf-8")
## Name of the printer (as returned from the ZeroConf properties)
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def name(self) -> str: def name(self) -> str:
"""Name of the printer (as returned from the ZeroConf properties)"""
return self._properties.get(b"name", b"").decode("utf-8") return self._properties.get(b"name", b"").decode("utf-8")
## Firmware version (as returned from the ZeroConf properties)
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def firmwareVersion(self) -> str: def firmwareVersion(self) -> str:
"""Firmware version (as returned from the ZeroConf properties)"""
return self._properties.get(b"firmware_version", b"").decode("utf-8") return self._properties.get(b"firmware_version", b"").decode("utf-8")
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def printerType(self) -> str: def printerType(self) -> str:
return self._properties.get(b"printer_type", b"Unknown").decode("utf-8") return self._properties.get(b"printer_type", b"Unknown").decode("utf-8")
## IP adress of this printer
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def ipAddress(self) -> str: def ipAddress(self) -> str:
"""IP adress of this printer"""
return self._address return self._address

View file

@ -2,15 +2,19 @@
# Cura is released under the terms of the LGPLv3 or higher. # 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: class Peripheral:
## Constructs the peripheral. """Data class that represents a peripheral for a printer.
# \param type A unique ID for the type of peripheral.
# \param name A human-readable name for the peripheral. 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: 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.type = peripheral_type
self.name = name self.name = name

View file

@ -24,8 +24,9 @@ if MYPY:
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")
## The current processing state of the backend.
class ConnectionState(IntEnum): class ConnectionState(IntEnum):
"""The current processing state of the backend."""
Closed = 0 Closed = 0
Connecting = 1 Connecting = 1
Connected = 2 Connected = 2
@ -40,17 +41,19 @@ class ConnectionType(IntEnum):
CloudConnection = 3 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 @signalemitter
class PrinterOutputDevice(QObject, OutputDevice): 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() printersChanged = pyqtSignal()
connectionStateChanged = pyqtSignal(str) connectionStateChanged = pyqtSignal(str)
@ -184,26 +187,30 @@ class PrinterOutputDevice(QObject, OutputDevice):
if self._monitor_item is None: if self._monitor_item is None:
self._monitor_item = QtApplication.getInstance().createQmlComponent(self._monitor_view_qml_path, {"OutputDevice": self}) self._monitor_item = QtApplication.getInstance().createQmlComponent(self._monitor_view_qml_path, {"OutputDevice": self})
## Attempt to establish connection
def connect(self) -> None: def connect(self) -> None:
"""Attempt to establish connection"""
self.setConnectionState(ConnectionState.Connecting) self.setConnectionState(ConnectionState.Connecting)
self._update_timer.start() self._update_timer.start()
## Attempt to close the connection
def close(self) -> None: def close(self) -> None:
"""Attempt to close the connection"""
self._update_timer.stop() self._update_timer.stop()
self.setConnectionState(ConnectionState.Closed) self.setConnectionState(ConnectionState.Closed)
## Ensure that close gets called when object is destroyed
def __del__(self) -> None: def __del__(self) -> None:
"""Ensure that close gets called when object is destroyed"""
self.close() self.close()
@pyqtProperty(bool, notify = acceptsCommandsChanged) @pyqtProperty(bool, notify = acceptsCommandsChanged)
def acceptsCommands(self) -> bool: def acceptsCommands(self) -> bool:
return self._accepts_commands 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: 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: if self._accepts_commands != accepts_commands:
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 # At this point there may be non-updated configurations
self._updateUniqueConfigurations() self._updateUniqueConfigurations()
## Set the device firmware name
#
# \param name The name of the firmware.
def _setFirmwareName(self, name: str) -> None: def _setFirmwareName(self, name: str) -> None:
"""Set the device firmware name
:param name: The name of the firmware.
"""
self._firmware_name = name self._firmware_name = name
## Get the name of device firmware
#
# This name can be used to define device type
def getFirmwareName(self) -> Optional[str]: def getFirmwareName(self) -> Optional[str]:
"""Get the name of device firmware
This name can be used to define device type
"""
return self._firmware_name return self._firmware_name
def getFirmwareUpdater(self) -> Optional["FirmwareUpdater"]: def getFirmwareUpdater(self) -> Optional["FirmwareUpdater"]:

View file

@ -10,15 +10,19 @@ class NoProfileException(Exception):
pass 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): 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): def __init__(self):
super().__init__() 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): 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.") raise NotImplementedError("Profile reader plug-in was not correctly implemented. The read function was not implemented.")

View file

@ -3,23 +3,29 @@
from UM.PluginObject import PluginObject 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): class ProfileWriter(PluginObject):
## Initialises the profile writer. """Base class for profile writer plugins.
#
# This currently doesn't do anything since the writer is basically static. This class defines a write() function to write profiles to files with.
"""
def __init__(self): def __init__(self):
"""Initialises the profile writer.
This currently doesn't do anything since the writer is basically static.
"""
super().__init__() 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): 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.") raise NotImplementedError("Profile writer plugin was not correctly implemented. No write was specified.")

View file

@ -2,8 +2,9 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
from cura.Scene.CuraSceneNode import CuraSceneNode from cura.Scene.CuraSceneNode import CuraSceneNode
## Make a SceneNode build plate aware CuraSceneNode objects all have this decorator.
class BuildPlateDecorator(SceneNodeDecorator): class BuildPlateDecorator(SceneNodeDecorator):
"""Make a SceneNode build plate aware CuraSceneNode objects all have this decorator."""
def __init__(self, build_plate_number: int = -1) -> None: def __init__(self, build_plate_number: int = -1) -> None:
super().__init__() super().__init__()
self._build_plate_number = build_plate_number self._build_plate_number = build_plate_number

View file

@ -23,9 +23,12 @@ if TYPE_CHECKING:
from UM.Math.Matrix import Matrix 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): 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: def __init__(self) -> None:
super().__init__() super().__init__()
@ -50,6 +53,8 @@ class ConvexHullDecorator(SceneNodeDecorator):
CuraApplication.getInstance().getController().toolOperationStarted.connect(self._onChanged) CuraApplication.getInstance().getController().toolOperationStarted.connect(self._onChanged)
CuraApplication.getInstance().getController().toolOperationStopped.connect(self._onChanged) CuraApplication.getInstance().getController().toolOperationStopped.connect(self._onChanged)
self._root = Application.getInstance().getController().getScene().getRoot()
self._onGlobalStackChanged() self._onGlobalStackChanged()
def createRecomputeConvexHullTimer(self) -> None: def createRecomputeConvexHullTimer(self) -> None:
@ -74,13 +79,16 @@ class ConvexHullDecorator(SceneNodeDecorator):
self._onChanged() self._onChanged()
## Force that a new (empty) object is created upon copy.
def __deepcopy__(self, memo): def __deepcopy__(self, memo):
"""Force that a new (empty) object is created upon copy."""
return ConvexHullDecorator() 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]: 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: if self._node is None:
return None return None
@ -90,9 +98,11 @@ class ConvexHullDecorator(SceneNodeDecorator):
return self._add2DAdhesionMargin(hull) 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]: 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: if self._node is None:
return None return None
if self._node.callDecoration("isNonPrintingMesh"): if self._node.callDecoration("isNonPrintingMesh"):
@ -108,9 +118,11 @@ class ConvexHullDecorator(SceneNodeDecorator):
return self._compute2DConvexHull() 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]: 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: if self._node is None:
return None return None
@ -126,10 +138,12 @@ class ConvexHullDecorator(SceneNodeDecorator):
return False return False
return bool(parent.callDecoration("isGroup")) 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]: 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: if self._node is None:
return None return None
if self._node.callDecoration("isNonPrintingMesh"): if self._node.callDecoration("isNonPrintingMesh"):
@ -142,10 +156,12 @@ class ConvexHullDecorator(SceneNodeDecorator):
return head_with_fans_with_adhesion_margin return head_with_fans_with_adhesion_margin
return None 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]: 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: if self._node is None:
return None return None
@ -157,10 +173,12 @@ class ConvexHullDecorator(SceneNodeDecorator):
return self._compute2DConvexHull() return self._compute2DConvexHull()
return None 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]: 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(): if self._isSingularOneAtATimeNode():
# In one-at-a-time mode, every printed object gets it's own adhesion # In one-at-a-time mode, every printed object gets it's own adhesion
printing_area = self.getAdhesionArea() printing_area = self.getAdhesionArea()
@ -168,8 +186,9 @@ class ConvexHullDecorator(SceneNodeDecorator):
printing_area = self.getConvexHull() printing_area = self.getConvexHull()
return printing_area return printing_area
## The same as recomputeConvexHull, but using a timer if it was set.
def recomputeConvexHullDelayed(self) -> None: 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: if self._recompute_convex_hull_timer is not None:
self._recompute_convex_hull_timer.start() self._recompute_convex_hull_timer.start()
else: else:
@ -181,23 +200,16 @@ class ConvexHullDecorator(SceneNodeDecorator):
CuraApplication.getInstance().callLater(self.recomputeConvexHullDelayed) CuraApplication.getInstance().callLater(self.recomputeConvexHullDelayed)
def recomputeConvexHull(self) -> None: def recomputeConvexHull(self) -> None:
controller = Application.getInstance().getController() if self._node is None or not self.__isDescendant(self._root, self._node):
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._convex_hull_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.setParent(None)
self._convex_hull_node = None self._convex_hull_node = None
return return
if self._convex_hull_node: if self._convex_hull_node:
self._convex_hull_node.setParent(None) 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 self._convex_hull_node = hull_node
def _onSettingValueChanged(self, key: str, property_name: str) -> None: def _onSettingValueChanged(self, key: str, property_name: str) -> None:
@ -224,7 +236,7 @@ class ConvexHullDecorator(SceneNodeDecorator):
if self._node is None: if self._node is None:
return None return None
if self._node.callDecoration("isGroup"): 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(): for child in self._node.getChildren():
child_hull = child.callDecoration("_compute2DConvexHull") child_hull = child.callDecoration("_compute2DConvexHull")
if child_hull: if child_hull:
@ -256,7 +268,7 @@ class ConvexHullDecorator(SceneNodeDecorator):
if mesh is None: if mesh is None:
return Polygon([]) # Node has no mesh data, so just return an empty Polygon. return Polygon([]) # Node has no mesh data, so just return an empty Polygon.
world_transform = self._node.getWorldTransformation() world_transform = self._node.getWorldTransformation(copy= False)
# Check the cache # Check the cache
if mesh is self._2d_convex_hull_mesh and world_transform == self._2d_convex_hull_mesh_world_transform: if mesh is self._2d_convex_hull_mesh and world_transform == self._2d_convex_hull_mesh_world_transform:
@ -268,7 +280,7 @@ class ConvexHullDecorator(SceneNodeDecorator):
# Do not throw away vertices: the convex hull may be too small and objects can collide. # 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] # 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 # 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 # 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. # becomes much less complex when dealing with highly detailed models.
@ -325,9 +337,11 @@ class ConvexHullDecorator(SceneNodeDecorator):
return convex_hull.getMinkowskiHull(head_and_fans) return convex_hull.getMinkowskiHull(head_and_fans)
return None return None
## Compensate given 2D polygon with adhesion margin
# \return 2D polygon with added margin
def _add2DAdhesionMargin(self, poly: Polygon) -> Polygon: def _add2DAdhesionMargin(self, poly: Polygon) -> Polygon:
"""Compensate given 2D polygon with adhesion margin
:return: 2D polygon with added margin
"""
if not self._global_stack: if not self._global_stack:
return Polygon() return Polygon()
# Compensate for raft/skirt/brim # Compensate for raft/skirt/brim
@ -358,12 +372,14 @@ class ConvexHullDecorator(SceneNodeDecorator):
poly = poly.getMinkowskiHull(extra_margin_polygon) poly = poly.getMinkowskiHull(extra_margin_polygon)
return poly 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: 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.
"""
horizontal_expansion = max( horizontal_expansion = max(
self._getSettingProperty("xy_offset", "value"), self._getSettingProperty("xy_offset", "value"),
self._getSettingProperty("xy_offset_layer_0", "value") self._getSettingProperty("xy_offset_layer_0", "value")
@ -409,8 +425,9 @@ class ConvexHullDecorator(SceneNodeDecorator):
self._onChanged() 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: 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: if self._global_stack is None or self._node is None:
return None return None
per_mesh_stack = self._node.callDecoration("getStack") per_mesh_stack = self._node.callDecoration("getStack")
@ -430,16 +447,18 @@ class ConvexHullDecorator(SceneNodeDecorator):
# Limit_to_extruder is set. The global stack handles this then # Limit_to_extruder is set. The global stack handles this then
return self._global_stack.getProperty(setting_key, prop) 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: 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: if node is None:
return False return False
if root is node: if root is node:
return True return True
return self.__isDescendant(root, node.getParent()) 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: 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: if self._node is None:
return False return False
return self._global_stack is not None \ return self._global_stack is not None \
@ -450,7 +469,8 @@ class ConvexHullDecorator(SceneNodeDecorator):
"adhesion_type", "raft_margin", "print_sequence", "adhesion_type", "raft_margin", "print_sequence",
"skirt_gap", "skirt_line_count", "skirt_brim_line_width", "skirt_distance", "brim_line_count"] "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"}
"""Settings that change the convex hull.
If these settings change, the convex hull should be recalculated.
"""

View file

@ -18,11 +18,13 @@ if TYPE_CHECKING:
class ConvexHullNode(SceneNode): class ConvexHullNode(SceneNode):
shader = None # To prevent the shader from being re-built over and over again, only load it once. 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: 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) super().__init__(parent)
self.setCalculateBoundingBox(False) self.setCalculateBoundingBox(False)
@ -55,11 +57,19 @@ class ConvexHullNode(SceneNode):
self._hull = hull self._hull = hull
if self._hull: if self._hull:
hull_mesh_builder = MeshBuilder() hull_mesh_builder = MeshBuilder()
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( if hull_mesh_builder.addConvexPolygonExtrusion(
self._hull.getPoints()[::-1], # bottom layer is reversed self._hull.getPoints()[::-1], # bottom layer is reversed
self._mesh_height - thickness, self._mesh_height, color = self._color): self._mesh_height - thickness, self._mesh_height, color = self._color):
hull_mesh_builder.resetNormals()
hull_mesh = hull_mesh_builder.build() hull_mesh = hull_mesh_builder.build()
self.setMeshData(hull_mesh) self.setMeshData(hull_mesh)
@ -77,11 +87,11 @@ class ConvexHullNode(SceneNode):
ConvexHullNode.shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader")) ConvexHullNode.shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader"))
ConvexHullNode.shader.setUniformValue("u_diffuseColor", self._color) ConvexHullNode.shader.setUniformValue("u_diffuseColor", self._color)
ConvexHullNode.shader.setUniformValue("u_opacity", 0.6) ConvexHullNode.shader.setUniformValue("u_opacity", 0.6)
batch = renderer.getNamedBatch("convex_hull_node")
if self.getParent(): if not batch:
if self.getMeshData() and isinstance(self._node, SceneNode) and self._node.callDecoration("getBuildPlateNumber") == Application.getInstance().getMultiBuildPlateModel().activeBuildPlate: batch = renderer.createRenderBatch(transparent = True, shader = ConvexHullNode.shader, backface_cull = True, sort = -8)
# The object itself (+ adhesion in one-at-a-time mode) renderer.addRenderBatch(batch, name = "convex_hull_node")
renderer.queueNode(self, transparent = True, shader = ConvexHullNode.shader, backface_cull = True, sort = -8) batch.addItem(self.getWorldTransformation(copy = False), self.getMeshData())
if self._convex_hull_head_mesh: 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 # 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 # cannot be printed after A, because the head would hit A while printing the current object
@ -95,7 +105,3 @@ class ConvexHullNode(SceneNode):
convex_hull_head_builder = MeshBuilder() convex_hull_head_builder = MeshBuilder()
convex_hull_head_builder.addConvexPolygon(convex_hull_head.getPoints(), self._mesh_height - self._thickness) convex_hull_head_builder.addConvexPolygon(convex_hull_head.getPoints(), self._mesh_height - self._thickness)
self._convex_hull_head_mesh = convex_hull_head_builder.build() self._convex_hull_head_mesh = convex_hull_head_builder.build()
if not node:
return

View file

@ -6,6 +6,7 @@ from PyQt5.QtWidgets import QApplication
from UM.Scene.Camera import Camera from UM.Scene.Camera import Camera
from cura.UI.ObjectsModel import ObjectsModel from cura.UI.ObjectsModel import ObjectsModel
from cura.Machines.Models.MultiBuildPlateModel import MultiBuildPlateModel from cura.Machines.Models.MultiBuildPlateModel import MultiBuildPlateModel
from cura.Scene.CuraSceneNode import CuraSceneNode
from UM.Application import Application from UM.Application import Application
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
@ -43,6 +44,26 @@ class CuraSceneController(QObject):
self._change_timer.start() self._change_timer.start()
def updateMaxBuildPlate(self, *args): 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() max_build_plate = self._calcMaxBuildPlate()
changed = False changed = False
if max_build_plate != self._max_build_plate: 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) max_build_plate = max(build_plate_number, max_build_plate)
return 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) @pyqtSlot(int)
def changeSelection(self, index): def changeSelection(self, index):
"""Either select or deselect an item"""
modifiers = QApplication.keyboardModifiers() modifiers = QApplication.keyboardModifiers()
ctrl_is_active = modifiers & Qt.ControlModifier ctrl_is_active = modifiers & Qt.ControlModifier
shift_is_active = modifiers & Qt.ShiftModifier shift_is_active = modifiers & Qt.ShiftModifier

View file

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
from copy import deepcopy 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. 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): 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: 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) super().__init__(parent = parent, visible = visible, name = name)
if not no_setting_override: if not no_setting_override:
@ -36,15 +38,23 @@ class CuraSceneNode(SceneNode):
def isSelectable(self) -> bool: def isSelectable(self) -> bool:
return super().isSelectable() and self.callDecoration("getBuildPlateNumber") == cura.CuraApplication.CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate 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 def isSupportMesh(self) -> bool:
# TODO The best way to do it is by adding the setActiveExtruder decorator to every node when is loaded 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]: 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() global_container_stack = Application.getInstance().getGlobalContainerStack()
if global_container_stack is None: if global_container_stack is None:
return None return None
per_mesh_stack = self.callDecoration("getStack") 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 # Use the support extruder instead of the active extruder if this is a support_mesh
if per_mesh_stack: if per_mesh_stack:
@ -69,8 +79,9 @@ class CuraSceneNode(SceneNode):
# This point should never be reached # This point should never be reached
return None return None
## Return the color of the material used to print this model
def getDiffuseColor(self) -> List[float]: def getDiffuseColor(self) -> List[float]:
"""Return the color of the material used to print this model"""
printing_extruder = self.getPrintingExtruder() printing_extruder = self.getPrintingExtruder()
material_color = "#808080" # Fallback color material_color = "#808080" # Fallback color
@ -86,8 +97,9 @@ class CuraSceneNode(SceneNode):
1.0 1.0
] ]
## Return if any area collides with the convex hull of this scene node
def collidesWithAreas(self, areas: List[Polygon]) -> bool: 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") convex_hull = self.callDecoration("getPrintingArea")
if convex_hull: if convex_hull:
if not convex_hull.isValid(): if not convex_hull.isValid():
@ -101,14 +113,15 @@ class CuraSceneNode(SceneNode):
return True return True
return False return False
## Override of SceneNode._calculateAABB to exclude non-printing-meshes from bounding box
def _calculateAABB(self) -> None: def _calculateAABB(self) -> None:
"""Override of SceneNode._calculateAABB to exclude non-printing-meshes from bounding box"""
self._aabb = None self._aabb = None
if self._mesh_data: if self._mesh_data:
self._aabb = self._mesh_data.getExtents(self.getWorldTransformation()) self._aabb = self._mesh_data.getExtents(self.getWorldTransformation(copy = False))
else: # If there is no mesh_data, use a boundingbox that encompasses the local (0,0,0) else: # If there is no mesh_data, use a bounding box that encompasses the local (0,0,0)
position = self.getWorldPosition() position = self.getWorldPosition()
self._aabb = AxisAlignedBox(minimum=position, maximum=position) self._aabb = AxisAlignedBox(minimum = position, maximum = position)
for child in self.getAllChildren(): for child in self.getAllChildren():
if child.callDecoration("isNonPrintingMesh"): if child.callDecoration("isNonPrintingMesh"):
@ -122,10 +135,11 @@ class CuraSceneNode(SceneNode):
else: else:
self._aabb = self._aabb + child.getBoundingBox() self._aabb = self._aabb + child.getBoundingBox()
## Taken from SceneNode, but replaced SceneNode with CuraSceneNode
def __deepcopy__(self, memo: Dict[int, object]) -> "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 = 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.setMeshData(self._mesh_data)
copy.setVisible(cast(bool, deepcopy(self._visible, memo))) copy.setVisible(cast(bool, deepcopy(self._visible, memo)))
copy._selectable = cast(bool, deepcopy(self._selectable, memo)) copy._selectable = cast(bool, deepcopy(self._selectable, memo))

View file

@ -1,8 +1,9 @@
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
## A decorator that stores the amount an object has been moved below the platform.
class ZOffsetDecorator(SceneNodeDecorator): class ZOffsetDecorator(SceneNodeDecorator):
"""A decorator that stores the amount an object has been moved below the platform."""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._z_offset = 0. self._z_offset = 0.

View file

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
import os import os
@ -33,12 +33,14 @@ if TYPE_CHECKING:
catalog = i18nCatalog("cura") 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): 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: def __init__(self, application: "CuraApplication") -> None:
if ContainerManager.__instance is not None: if ContainerManager.__instance is not None:
@ -67,21 +69,23 @@ class ContainerManager(QObject):
return "" return ""
return str(result) 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) @pyqtSlot("QVariant", str, str)
def setContainerMetaDataEntry(self, container_node: "ContainerNode", entry_name: str, entry_value: str) -> bool: 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: if container_node.container is None:
Logger.log("w", "Container node {0} doesn't have a container.".format(container_node.container_id)) Logger.log("w", "Container node {0} doesn't have a container.".format(container_node.container_id))
return False return False
@ -124,18 +128,20 @@ class ContainerManager(QObject):
def makeUniqueName(self, original_name: str) -> str: def makeUniqueName(self, original_name: str) -> str:
return cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry().uniqueName(original_name) 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") @pyqtSlot(str, result = "QStringList")
def getContainerNameFilters(self, type_name: str) -> List[str]: 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: if not self._container_name_filters:
self._updateContainerNameFilters() self._updateContainerNameFilters()
@ -147,17 +153,18 @@ class ContainerManager(QObject):
filters.append("All Files (*)") filters.append("All Files (*)")
return filters 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") @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]: 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: if not container_id or not file_type or not file_url_or_string:
return {"status": "error", "message": "Invalid arguments"} return {"status": "error", "message": "Invalid arguments"}
@ -206,19 +213,24 @@ class ContainerManager(QObject):
if contents is None: if contents is None:
return {"status": "error", "message": "Serialization returned None. Unable to write to file"} return {"status": "error", "message": "Serialization returned None. Unable to write to file"}
try:
with SaveFile(file_url, "w") as f: with SaveFile(file_url, "w") as f:
f.write(contents) 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} 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") @pyqtSlot(QUrl, result = "QVariantMap")
def importMaterialContainer(self, file_url_or_string: Union[QUrl, str]) -> Dict[str, str]: 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: if not file_url_or_string:
return {"status": "error", "message": "Invalid path"} return {"status": "error", "message": "Invalid path"}
@ -239,6 +251,8 @@ class ContainerManager(QObject):
container_type = container_registry.getContainerForMimeType(mime_type) container_type = container_registry.getContainerForMimeType(mime_type)
if not container_type: if not container_type:
return {"status": "error", "message": "Could not find a container to handle the specified file."} 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 = urllib.parse.unquote_plus(mime_type.stripExtension(os.path.basename(file_url)))
container_id = container_registry.uniqueName(container_id) container_id = container_registry.uniqueName(container_id)
@ -261,14 +275,16 @@ class ContainerManager(QObject):
return {"status": "success", "message": "Successfully imported container {0}".format(container.getName())} 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) @pyqtSlot(result = bool)
def updateQualityChanges(self) -> 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() application = cura.CuraApplication.CuraApplication.getInstance()
global_stack = application.getMachineManager().activeMachine global_stack = application.getMachineManager().activeMachine
if not global_stack: if not global_stack:
@ -278,7 +294,7 @@ class ContainerManager(QObject):
current_quality_changes_name = global_stack.qualityChanges.getName() current_quality_changes_name = global_stack.qualityChanges.getName()
current_quality_type = global_stack.quality.getMetaDataEntry("quality_type") 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() container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
machine_definition_id = ContainerTree.getInstance().machines[global_stack.definition.getId()].quality_definition machine_definition_id = ContainerTree.getInstance().machines[global_stack.definition.getId()].quality_definition
for stack in [global_stack] + extruder_stacks: for stack in [global_stack] + extruder_stacks:
@ -308,9 +324,10 @@ class ContainerManager(QObject):
return True return True
## Clear the top-most (user) containers of the active stacks.
@pyqtSlot() @pyqtSlot()
def clearUserContainers(self) -> None: def clearUserContainers(self) -> None:
"""Clear the top-most (user) containers of the active stacks."""
machine_manager = cura.CuraApplication.CuraApplication.getInstance().getMachineManager() machine_manager = cura.CuraApplication.CuraApplication.getInstance().getMachineManager()
machine_manager.blurSettings.emit() machine_manager.blurSettings.emit()
@ -318,8 +335,7 @@ class ContainerManager(QObject):
# Go through global and extruder stacks and clear their topmost container (the user settings). # Go through global and extruder stacks and clear their topmost container (the user settings).
global_stack = machine_manager.activeMachine global_stack = machine_manager.activeMachine
extruder_stacks = list(global_stack.extruders.values()) for stack in [global_stack] + global_stack.extruderList:
for stack in [global_stack] + extruder_stacks:
container = stack.userChanges container = stack.userChanges
container.clear() container.clear()
send_emits_containers.append(container) send_emits_containers.append(container)
@ -330,25 +346,28 @@ class ContainerManager(QObject):
for container in send_emits_containers: for container in send_emits_containers:
container.sendPostponedEmits() 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") @pyqtSlot("QVariant", bool, result = "QStringList")
def getLinkedMaterials(self, material_node: "MaterialNode", exclude_self: bool = False) -> List[str]: 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) same_guid = ContainerRegistry.getInstance().findInstanceContainersMetadata(GUID = material_node.guid)
if exclude_self: if exclude_self:
return list({meta["name"] for meta in same_guid if meta["base_file"] != material_node.base_file}) return list({meta["name"] for meta in same_guid if meta["base_file"] != material_node.base_file})
else: else:
return list({meta["name"] for meta in same_guid}) 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") @pyqtSlot("QVariant")
def unlinkMaterial(self, material_node: "MaterialNode") -> None: 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 # Get the material group
if material_node.container is None: # Failed to lazy-load this container. if material_node.container is None: # Failed to lazy-load this container.
return return
@ -423,9 +442,10 @@ class ContainerManager(QObject):
name_filter = "{0} ({1})".format(mime_type.comment, suffix_list) name_filter = "{0} ({1})".format(mime_type.comment, suffix_list)
self._container_name_filters[name_filter] = entry self._container_name_filters[name_filter] = entry
## Import single profile, file_url does not have to end with curaprofile
@pyqtSlot(QUrl, result = "QVariantMap") @pyqtSlot(QUrl, result = "QVariantMap")
def importProfile(self, file_url: QUrl) -> Dict[str, str]: 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(): if not file_url.isValid():
return {"status": "error", "message": catalog.i18nc("@info:status", "Invalid file URL:") + " " + str(file_url)} return {"status": "error", "message": catalog.i18nc("@info:status", "Invalid file URL:") + " " + str(file_url)}
path = file_url.toLocalFile() path = file_url.toLocalFile()

View file

@ -44,14 +44,16 @@ class CuraContainerRegistry(ContainerRegistry):
# is added, we check to see if an extruder stack needs to be added. # is added, we check to see if an extruder stack needs to be added.
self.containerAdded.connect(self._onContainerAdded) self.containerAdded.connect(self._onContainerAdded)
## 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) @override(ContainerRegistry)
def addContainer(self, container: ContainerInterface) -> None: 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 # Note: Intentional check with type() because we want to ignore subclasses
if type(container) == ContainerStack: if type(container) == ContainerStack:
container = self._convertContainerStack(cast(ContainerStack, container)) container = self._convertContainerStack(cast(ContainerStack, container))
@ -62,17 +64,19 @@ class CuraContainerRegistry(ContainerRegistry):
actual_setting_version = int(container.getMetaDataEntry("setting_version", default = 0)) actual_setting_version = int(container.getMetaDataEntry("setting_version", default = 0))
if required_setting_version != actual_setting_version: 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)) 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. return False # Don't add.
super().addContainer(container) return 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: 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() new_name = new_name.strip()
num_check = re.compile(r"(.*?)\s*#\d+$").match(new_name) num_check = re.compile(r"(.*?)\s*#\d+$").match(new_name)
if num_check: if num_check:
@ -89,24 +93,28 @@ class CuraContainerRegistry(ContainerRegistry):
return unique_name 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): 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 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 \ 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) 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: 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. # Parse the fileType to deduce what plugin can save the file format.
# fileType has the format "<description> (*.<extension>)" # fileType has the format "<description> (*.<extension>)"
split = file_type.rfind(" (*.") # Find where the description ends and the extension starts. split = file_type.rfind(" (*.") # Find where the description ends and the extension starts.
@ -150,11 +158,13 @@ class CuraContainerRegistry(ContainerRegistry):
m.show() m.show()
return True 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]: 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() plugin_registry = PluginRegistry.getInstance()
for plugin_id, meta_data in self._getIOPlugins("profile_writer"): 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. for supported_type in meta_data["profile_writer"]: # All file types this plugin can supposedly write.
@ -165,12 +175,14 @@ class CuraContainerRegistry(ContainerRegistry):
return cast(ProfileWriter, plugin_registry.getPluginObject(plugin_id)) return cast(ProfileWriter, plugin_registry.getPluginObject(plugin_id))
return None 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]: 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' or 'error',
and a 'message' key containing a message for the user.
"""
Logger.log("d", "Attempting to import profile %s", file_name) Logger.log("d", "Attempting to import profile %s", file_name)
if not 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")} 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")}
@ -180,9 +192,7 @@ class CuraContainerRegistry(ContainerRegistry):
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)} 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() container_tree = ContainerTree.getInstance()
machine_extruders = [] machine_extruders = global_stack.extruderList
for position in sorted(global_stack.extruders):
machine_extruders.append(global_stack.extruders[position])
plugin_registry = PluginRegistry.getInstance() plugin_registry = PluginRegistry.getInstance()
extension = file_name.split(".")[-1] extension = file_name.split(".")[-1]
@ -216,7 +226,7 @@ class CuraContainerRegistry(ContainerRegistry):
global_profile = profile global_profile = profile
else: else:
extruder_profiles.append(profile) extruder_profiles.append(profile)
extruder_profiles = sorted(extruder_profiles, key = lambda x: int(x.getMetaDataEntry("position"))) extruder_profiles = sorted(extruder_profiles, key = lambda x: int(x.getMetaDataEntry("position", default = "0")))
profile_or_list = [global_profile] + extruder_profiles profile_or_list = [global_profile] + extruder_profiles
if not global_profile: if not global_profile:
@ -263,7 +273,7 @@ class CuraContainerRegistry(ContainerRegistry):
if len(profile_or_list) == 1: if len(profile_or_list) == 1:
global_profile = profile_or_list[0] global_profile = profile_or_list[0]
extruder_profiles = [] extruder_profiles = []
for idx, extruder in enumerate(global_stack.extruders.values()): for idx, extruder in enumerate(global_stack.extruderList):
profile_id = ContainerRegistry.getInstance().uniqueName(global_stack.getId() + "_extruder_" + str(idx + 1)) profile_id = ContainerRegistry.getInstance().uniqueName(global_stack.getId() + "_extruder_" + str(idx + 1))
profile = InstanceContainer(profile_id) profile = InstanceContainer(profile_id)
profile.setName(quality_name) profile.setName(quality_name)
@ -338,12 +348,42 @@ class CuraContainerRegistry(ContainerRegistry):
self._registerSingleExtrusionMachinesExtruderStacks() self._registerSingleExtrusionMachinesExtruderStacks()
self._connectUpgradedExtruderStacksToMachines() self._connectUpgradedExtruderStacksToMachines()
## Check if the metadata for a container is okay before adding it. @override(ContainerRegistry)
# def loadAllMetadata(self) -> None:
# This overrides the one from UM.Settings.ContainerRegistry because we super().loadAllMetadata()
# also require that the setting_version is correct. 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) @override(ContainerRegistry)
def _isMetadataValid(self, metadata: Optional[Dict[str, Any]]) -> bool: 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: if metadata is None:
return False return False
if "setting_version" not in metadata: if "setting_version" not in metadata:
@ -355,14 +395,16 @@ class CuraContainerRegistry(ContainerRegistry):
return False return False
return True 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]: def _configureProfile(self, profile: InstanceContainer, id_seed: str, new_name: str, machine_definition_id: str) -> 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.
:return: None if configuring was successful or an error message if an error occurred.
"""
profile.setDirty(True) # Ensure the profiles are correctly saved profile.setDirty(True) # Ensure the profiles are correctly saved
new_id = self.createUniqueName("quality_changes", "", id_seed, catalog.i18nc("@label", "Custom profile")) new_id = self.createUniqueName("quality_changes", "", id_seed, catalog.i18nc("@label", "Custom profile"))
@ -397,7 +439,8 @@ class CuraContainerRegistry(ContainerRegistry):
if quality_type != empty_quality_container.getMetaDataEntry("quality_type") and quality_type not in quality_group_dict: 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) return catalog.i18nc("@info:status", "Could not find a quality type {0} for the current configuration.", quality_type)
ContainerRegistry.getInstance().addContainer(profile) if not self.addContainer(profile):
return catalog.i18nc("@info:status", "Unable to add the profile.")
return None return None
@ -420,9 +463,11 @@ class CuraContainerRegistry(ContainerRegistry):
for stack in self.findContainerStacks(): for stack in self.findContainerStacks():
self.saveContainer(stack) self.saveContainer(stack)
## Gets a list of profile writer plugins
# \return List of tuples of (plugin_id, meta_data).
def _getIOPlugins(self, io_type): 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() plugin_registry = PluginRegistry.getInstance()
active_plugin_ids = plugin_registry.getActivePlugins() active_plugin_ids = plugin_registry.getActivePlugins()
@ -433,8 +478,9 @@ class CuraContainerRegistry(ContainerRegistry):
result.append( (plugin_id, meta_data) ) result.append( (plugin_id, meta_data) )
return result 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]: 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 assert type(container) == ContainerStack
container_type = container.getMetaDataEntry("type") container_type = container.getMetaDataEntry("type")

View file

@ -18,25 +18,27 @@ from cura.Settings import cura_empty_instance_containers
from . import Exceptions 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): 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: def __init__(self, container_id: str) -> None:
super().__init__(container_id) super().__init__(container_id)
@ -58,104 +60,136 @@ class CuraContainerStack(ContainerStack):
import cura.CuraApplication #Here to prevent circular imports. import cura.CuraApplication #Here to prevent circular imports.
self.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.SettingVersion) self.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.SettingVersion)
self.setDirty(False)
# This is emitted whenever the containersChanged signal from the ContainerStack base class is emitted. # This is emitted whenever the containersChanged signal from the ContainerStack base class is emitted.
pyqtContainersChanged = pyqtSignal() 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: 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) 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) @pyqtProperty(InstanceContainer, fset = setUserChanges, notify = pyqtContainersChanged)
def userChanges(self) -> InstanceContainer: 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]) 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: 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) 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) @pyqtProperty(InstanceContainer, fset = setQualityChanges, notify = pyqtContainersChanged)
def qualityChanges(self) -> InstanceContainer: 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]) 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: 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) 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) @pyqtProperty(InstanceContainer, fset = setIntent, notify = pyqtContainersChanged)
def intent(self) -> InstanceContainer: 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]) 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: 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) 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) @pyqtProperty(InstanceContainer, fset = setQuality, notify = pyqtContainersChanged)
def quality(self) -> InstanceContainer: 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]) 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: 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) 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) @pyqtProperty(InstanceContainer, fset = setMaterial, notify = pyqtContainersChanged)
def material(self) -> InstanceContainer: 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]) 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: 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) 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) @pyqtProperty(InstanceContainer, fset = setVariant, notify = pyqtContainersChanged)
def variant(self) -> InstanceContainer: 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]) 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: 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) 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) @pyqtProperty(InstanceContainer, fset = setDefinitionChanges, notify = pyqtContainersChanged)
def definitionChanges(self) -> InstanceContainer: 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]) 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: 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) self.replaceContainer(_ContainerIndexes.Definition, new_definition)
def getDefinition(self) -> "DefinitionContainer": def getDefinition(self) -> "DefinitionContainer":
@ -171,14 +205,16 @@ class CuraContainerStack(ContainerStack):
def getTop(self) -> "InstanceContainer": def getTop(self) -> "InstanceContainer":
return self.userChanges 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) @pyqtSlot(str, result = bool)
def hasUserValue(self, key: str) -> 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"): if self._containers[_ContainerIndexes.UserChanges].hasProperty(key, "value"):
return True return True
@ -187,51 +223,61 @@ class CuraContainerStack(ContainerStack):
return False 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: 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 container_index = _ContainerIndexes.UserChanges
self._containers[container_index].setProperty(key, property_name, property_value, container, set_from_cache) 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) @override(ContainerStack)
def addContainer(self, container: ContainerInterface) -> None: 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") 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) @override(ContainerStack)
def insertContainer(self, index: int, container: ContainerInterface) -> None: 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") 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) @override(ContainerStack)
def removeContainer(self, index: int = 0) -> None: 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") 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) @override(ContainerStack)
def replaceContainer(self, index: int, container: ContainerInterface, postpone_emit: bool = False) -> None: 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] expected_type = _ContainerIndexes.IndexTypeMap[index]
if expected_type == "definition": if expected_type == "definition":
if not isinstance(container, DefinitionContainer): if not isinstance(container, DefinitionContainer):
@ -245,16 +291,18 @@ class CuraContainerStack(ContainerStack):
super().replaceContainer(index, container, postpone_emit) 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) @override(ContainerStack)
def deserialize(self, serialized: str, file_name: Optional[str] = None) -> str: 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 # update the serialized data first
serialized = super().deserialize(serialized, file_name) serialized = super().deserialize(serialized, file_name)
@ -298,10 +346,9 @@ class CuraContainerStack(ContainerStack):
## TODO; Deserialize the containers. ## TODO; Deserialize the containers.
return serialized return serialized
## protected:
# Helper to make sure we emit a PyQt signal on container changes.
def _onContainersChanged(self, container: Any) -> None: def _onContainersChanged(self, container: Any) -> None:
"""Helper to make sure we emit a PyQt signal on container changes."""
Application.getInstance().callLater(self.pyqtContainersChanged.emit) Application.getInstance().callLater(self.pyqtContainersChanged.emit)
# Helper that can be overridden to get the "machine" definition, that is, the definition that defines the machine # Helper that can be overridden to get the "machine" definition, that is, the definition that defines the machine
@ -309,16 +356,18 @@ class CuraContainerStack(ContainerStack):
def _getMachineDefinition(self) -> DefinitionContainer: def _getMachineDefinition(self) -> DefinitionContainer:
return self.definition 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 @classmethod
def _findInstanceContainerDefinitionId(cls, machine_definition: DefinitionContainerInterface) -> str: 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") quality_definition = machine_definition.getMetaDataEntry("quality_definition")
if not quality_definition: if not quality_definition:
return machine_definition.id #type: ignore return machine_definition.id #type: ignore
@ -330,17 +379,18 @@ class CuraContainerStack(ContainerStack):
return cls._findInstanceContainerDefinitionId(definitions[0]) return cls._findInstanceContainerDefinitionId(definitions[0])
## getProperty for extruder positions, with translation from -1 to default extruder number
def getExtruderPositionValueWithDefault(self, key): def getExtruderPositionValueWithDefault(self, key):
"""getProperty for extruder positions, with translation from -1 to default extruder number"""
value = self.getProperty(key, "value") value = self.getProperty(key, "value")
if value == -1: if value == -1:
value = int(Application.getInstance().getMachineManager().defaultExtruderPosition) value = int(Application.getInstance().getMachineManager().defaultExtruderPosition)
return value return value
## private:
# Private helper class to keep track of container positions and their types.
class _ContainerIndexes: class _ContainerIndexes:
"""Private helper class to keep track of container positions and their types."""
UserChanges = 0 UserChanges = 0
QualityChanges = 1 QualityChanges = 1
Intent = 2 Intent = 2

View file

@ -9,22 +9,24 @@ from UM.Settings.Interfaces import DefinitionContainerInterface
from UM.Settings.InstanceContainer import InstanceContainer from UM.Settings.InstanceContainer import InstanceContainer
from cura.Machines.ContainerTree import ContainerTree from cura.Machines.ContainerTree import ContainerTree
from cura.Machines.MachineNode import MachineNode
from .GlobalStack import GlobalStack from .GlobalStack import GlobalStack
from .ExtruderStack import ExtruderStack from .ExtruderStack import ExtruderStack
## Contains helper functions to create new machines.
class CuraStackBuilder: 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 @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 from cura.CuraApplication import CuraApplication
application = CuraApplication.getInstance() application = CuraApplication.getInstance()
registry = application.getContainerRegistry() registry = application.getContainerRegistry()
@ -60,10 +62,18 @@ class CuraStackBuilder:
for position in extruder_dict: for position in extruder_dict:
try: try:
cls.createExtruderStackWithDefaultSetup(new_global_stack, position) cls.createExtruderStackWithDefaultSetup(new_global_stack, position)
except IndexError: 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 return None
for new_extruder in new_global_stack.extruders.values(): # Only register the extruders if we're sure that all of them are correct. # 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) registry.addContainer(new_extruder)
# Register the global stack after the extruder stacks are created. This prevents the registry from adding another # Register the global stack after the extruder stacks are created. This prevents the registry from adding another
@ -72,12 +82,14 @@ class CuraStackBuilder:
return new_global_stack 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 @classmethod
def createExtruderStackWithDefaultSetup(cls, global_stack: "GlobalStack", extruder_position: int) -> None: 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 from cura.CuraApplication import CuraApplication
application = CuraApplication.getInstance() application = CuraApplication.getInstance()
registry = application.getContainerRegistry() registry = application.getContainerRegistry()
@ -121,17 +133,6 @@ class CuraStackBuilder:
registry.addContainer(new_extruder) 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 @classmethod
def createExtruderStack(cls, new_stack_id: str, extruder_definition: DefinitionContainerInterface, def createExtruderStack(cls, new_stack_id: str, extruder_definition: DefinitionContainerInterface,
machine_definition_id: str, machine_definition_id: str,
@ -140,6 +141,19 @@ class CuraStackBuilder:
material_container: "InstanceContainer", material_container: "InstanceContainer",
quality_container: "InstanceContainer") -> ExtruderStack: 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 from cura.CuraApplication import CuraApplication
application = CuraApplication.getInstance() application = CuraApplication.getInstance()
registry = application.getContainerRegistry() registry = application.getContainerRegistry()
@ -168,29 +182,23 @@ class CuraStackBuilder:
return stack 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 @classmethod
def createGlobalStack(cls, new_stack_id: str, definition: DefinitionContainerInterface, def createGlobalStack(cls, new_stack_id: str, definition: DefinitionContainerInterface,
variant_container: "InstanceContainer", variant_container: "InstanceContainer",
material_container: "InstanceContainer", material_container: "InstanceContainer",
quality_container: "InstanceContainer") -> GlobalStack: 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 from cura.CuraApplication import CuraApplication
application = CuraApplication.getInstance() application = CuraApplication.getInstance()
registry = application.getContainerRegistry() registry = application.getContainerRegistry()

View file

@ -2,21 +2,25 @@
# Cura is released under the terms of the LGPLv3 or higher. # 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): class InvalidOperationError(Exception):
"""Raised when trying to perform an operation like add on a stack that does not allow that."""
pass pass
## Raised when trying to replace a container with a container that does not have the expected type.
class InvalidContainerError(Exception): class InvalidContainerError(Exception):
"""Raised when trying to replace a container with a container that does not have the expected type."""
pass pass
## Raised when trying to add an extruder to a Global stack that already has the maximum number of extruders.
class TooManyExtrudersError(Exception): class TooManyExtrudersError(Exception):
"""Raised when trying to add an extruder to a Global stack that already has the maximum number of extruders."""
pass pass
## Raised when an extruder has no next stack set.
class NoGlobalStackError(Exception): class NoGlobalStackError(Exception):
"""Raised when an extruder has no next stack set."""
pass pass

View file

@ -19,13 +19,15 @@ if TYPE_CHECKING:
from cura.Settings.ExtruderStack import ExtruderStack from cura.Settings.ExtruderStack import ExtruderStack
## Manages all existing extruder stacks.
#
# This keeps a list of extruder stacks for each machine.
class ExtruderManager(QObject): 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): def __init__(self, parent = None):
"""Registers listeners and such to listen to changes to the extruders."""
if ExtruderManager.__instance is not None: if ExtruderManager.__instance is not None:
raise RuntimeError("Try to create singleton '%s' more than once" % self.__class__.__name__) raise RuntimeError("Try to create singleton '%s' more than once" % self.__class__.__name__)
ExtruderManager.__instance = self ExtruderManager.__instance = self
@ -43,20 +45,22 @@ class ExtruderManager(QObject):
Selection.selectionChanged.connect(self.resetSelectedObjectExtruders) Selection.selectionChanged.connect(self.resetSelectedObjectExtruders)
## Signal to notify other components when the list of extruders for a machine definition changes.
extrudersChanged = pyqtSignal(QVariant) 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() 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) @pyqtProperty(str, notify = activeExtruderChanged)
def activeExtruderStackId(self) -> Optional[str]: 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(): if not self._application.getGlobalContainerStack():
return None # No active machine, so no active extruder. return None # No active machine, so no active extruder.
try: 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. 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 return None
## Gets a dict with the extruder stack ids with the extruder number as the key.
@pyqtProperty("QVariantMap", notify = extrudersChanged) @pyqtProperty("QVariantMap", notify = extrudersChanged)
def extruderIds(self) -> Dict[str, str]: 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] extruder_stack_ids = {} # type: Dict[str, str]
global_container_stack = self._application.getGlobalContainerStack() global_container_stack = self._application.getGlobalContainerStack()
@ -75,11 +80,13 @@ class ExtruderManager(QObject):
return extruder_stack_ids return extruder_stack_ids
## Changes the active extruder by index.
#
# \param index The index of the new active extruder.
@pyqtSlot(int) @pyqtSlot(int)
def setActiveExtruderIndex(self, index: int) -> None: 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: if self._active_extruder_index != index:
self._active_extruder_index = index self._active_extruder_index = index
self.activeExtruderChanged.emit() self.activeExtruderChanged.emit()
@ -88,12 +95,13 @@ class ExtruderManager(QObject):
def activeExtruderIndex(self) -> int: def activeExtruderIndex(self) -> int:
return self._active_extruder_index return self._active_extruder_index
## Emitted whenever the selectedObjectExtruders property changes.
selectedObjectExtrudersChanged = pyqtSignal() selectedObjectExtrudersChanged = pyqtSignal()
"""Emitted whenever the selectedObjectExtruders property changes."""
## Provides a list of extruder IDs used by the current selected objects.
@pyqtProperty("QVariantList", notify = selectedObjectExtrudersChanged) @pyqtProperty("QVariantList", notify = selectedObjectExtrudersChanged)
def selectedObjectExtruders(self) -> List[Union[str, "ExtruderStack"]]: 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: if not self._selected_object_extruders:
object_extruders = set() object_extruders = set()
@ -122,11 +130,13 @@ class ExtruderManager(QObject):
return self._selected_object_extruders 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: 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._selected_object_extruders = []
self.selectedObjectExtrudersChanged.emit() self.selectedObjectExtrudersChanged.emit()
@ -134,8 +144,9 @@ class ExtruderManager(QObject):
def getActiveExtruderStack(self) -> Optional["ExtruderStack"]: def getActiveExtruderStack(self) -> Optional["ExtruderStack"]:
return self.getExtruderStack(self.activeExtruderIndex) return self.getExtruderStack(self.activeExtruderIndex)
## Get an extruder stack by index
def getExtruderStack(self, index) -> Optional["ExtruderStack"]: def getExtruderStack(self, index) -> Optional["ExtruderStack"]:
"""Get an extruder stack by index"""
global_container_stack = self._application.getGlobalContainerStack() global_container_stack = self._application.getGlobalContainerStack()
if global_container_stack: if global_container_stack:
if global_container_stack.getId() in self._extruder_trains: 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 self._extruder_trains[global_container_stack.getId()][str(index)]
return None 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]: 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 = [] result = []
for extruder_stack in self.getActiveExtruderStacks(): for extruder_stack in self.getActiveExtruderStacks():
@ -182,17 +176,19 @@ class ExtruderManager(QObject):
else: else:
return value 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"]: 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() global_stack = self._application.getGlobalContainerStack()
container_registry = ContainerRegistry.getInstance() container_registry = ContainerRegistry.getInstance()
@ -208,33 +204,34 @@ class ExtruderManager(QObject):
# If no extruders are registered in the extruder manager yet, return an empty array # If no extruders are registered in the extruder manager yet, return an empty array
if len(self.extruderIds) == 0: if len(self.extruderIds) == 0:
return [] return []
number_active_extruders = len([extruder for extruder in self.getActiveExtruderStacks() if extruder.isEnabled])
# Get the extruders of all printable meshes in the scene # 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 for node in nodes:
mesh_list = [] extruder_stack_id = node.callDecoration("getActiveExtruder")
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")
if not extruder_stack_id: if not extruder_stack_id:
# No per-object settings for this node # No per-object settings for this node
extruder_stack_id = self.extruderIds["0"] extruder_stack_id = self.extruderIds["0"]
used_extruder_stack_ids.add(extruder_stack_id) 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. # 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 not stack_to_use:
# if there is no per-mesh stack, we use the build extruder for this mesh # 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] stack_to_use = container_registry.findContainerStacks(id = extruder_stack_id)[0]
if not support_enabled:
support_enabled |= stack_to_use.getProperty("support_enable", "value") 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") 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") support_roof_enabled |= stack_to_use.getProperty("support_roof_enable", "value")
# Check limit to extruders # Check limit to extruders
@ -248,6 +245,8 @@ class ExtruderManager(QObject):
extruder_nr = int(global_stack.getProperty(extruder_nr_feature_name, "value")) extruder_nr = int(global_stack.getProperty(extruder_nr_feature_name, "value"))
if extruder_nr == -1: if extruder_nr == -1:
continue 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)]) used_extruder_stack_ids.add(self.extruderIds[str(extruder_nr)])
# Check support extruders # Check support extruders
@ -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) Logger.log("e", "Unable to find one or more of the extruders in %s", used_extruder_stack_ids)
return [] return []
## Get the extruder that the print will start with.
#
# This should mirror the implementation in CuraEngine of
# ``FffGcodeWriter::getStartExtruder()``.
def getInitialExtruderNr(self) -> int: 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() application = cura.CuraApplication.CuraApplication.getInstance()
global_stack = application.getGlobalContainerStack() global_stack = application.getGlobalContainerStack()
@ -288,34 +289,41 @@ class ExtruderManager(QObject):
return global_stack.getProperty("adhesion_extruder_nr", "value") return global_stack.getProperty("adhesion_extruder_nr", "value")
# No adhesion? Well maybe there is still support brim. # No adhesion? Well maybe there is still support brim.
if (global_stack.getProperty("support_enable", "value") or global_stack.getProperty("support_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") return global_stack.getProperty("support_infill_extruder_nr", "value")
# REALLY no adhesion? Use the first used extruder. # REALLY no adhesion? Use the first used extruder.
return self.getUsedExtruderStacks()[0].getProperty("extruder_nr", "value") 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: 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): for extruder in self.getMachineExtruders(machine_id):
ContainerRegistry.getInstance().removeContainer(extruder.userChanges.getId()) ContainerRegistry.getInstance().removeContainer(extruder.userChanges.getId())
ContainerRegistry.getInstance().removeContainer(extruder.definitionChanges.getId())
ContainerRegistry.getInstance().removeContainer(extruder.getId()) ContainerRegistry.getInstance().removeContainer(extruder.getId())
if machine_id in self._extruder_trains: if machine_id in self._extruder_trains:
del self._extruder_trains[machine_id] 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"]: 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: if machine_id not in self._extruder_trains:
return [] return []
return [self._extruder_trains[machine_id][name] for name in self._extruder_trains[machine_id]] 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"]: 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() global_stack = self._application.getGlobalContainerStack()
if not global_stack: if not global_stack:
return [] return []
@ -327,8 +335,9 @@ class ExtruderManager(QObject):
self.resetSelectedObjectExtruders() self.resetSelectedObjectExtruders()
## Adds the extruders to the selected machine.
def addMachineExtruders(self, global_stack: GlobalStack) -> None: def addMachineExtruders(self, global_stack: GlobalStack) -> None:
"""Adds the extruders to the selected machine."""
extruders_changed = False extruders_changed = False
container_registry = ContainerRegistry.getInstance() container_registry = ContainerRegistry.getInstance()
global_stack_id = global_stack.getId() global_stack_id = global_stack.getId()
@ -394,26 +403,30 @@ class ExtruderManager(QObject):
raise IndexError(msg) raise IndexError(msg)
extruder_stack_0.definition = extruder_definition 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") @pyqtSlot(str, result="QVariant")
def getInstanceExtruderValues(self, key: str) -> List: 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) 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 @staticmethod
def getResolveOrValue(key: str) -> Any: 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()) global_stack = cast(GlobalStack, cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack())
resolved_value = global_stack.getProperty(key, "value") resolved_value = global_stack.getProperty(key, "value")

View file

@ -22,10 +22,9 @@ if TYPE_CHECKING:
from cura.Settings.GlobalStack import GlobalStack from cura.Settings.GlobalStack import GlobalStack
## Represents an Extruder and its related containers.
#
#
class ExtruderStack(CuraContainerStack): class ExtruderStack(CuraContainerStack):
"""Represents an Extruder and its related containers."""
def __init__(self, container_id: str) -> None: def __init__(self, container_id: str) -> None:
super().__init__(container_id) super().__init__(container_id)
@ -33,20 +32,21 @@ class ExtruderStack(CuraContainerStack):
self.propertiesChanged.connect(self._onPropertiesChanged) self.propertiesChanged.connect(self._onPropertiesChanged)
self.setDirty(False)
enabledChanged = pyqtSignal() enabledChanged = pyqtSignal()
## Overridden from ContainerStack
#
# This will set the next stack and ensure that we register this stack as an extruder.
@override(ContainerStack) @override(ContainerStack)
def setNextStack(self, stack: CuraContainerStack, connect_signals: bool = True) -> None: 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) super().setNextStack(stack)
stack.addExtruder(self) stack.addExtruder(self)
self.setMetaDataEntry("machine", stack.id) self.setMetaDataEntry("machine", stack.id)
# For backward compatibility: Register the extruder with the Extruder Manager
ExtruderManager.getInstance().registerExtruder(self, stack.id)
@override(ContainerStack) @override(ContainerStack)
def getNextStack(self) -> Optional["GlobalStack"]: def getNextStack(self) -> Optional["GlobalStack"]:
return super().getNextStack() return super().getNextStack()
@ -71,11 +71,13 @@ class ExtruderStack(CuraContainerStack):
compatibleMaterialDiameterChanged = pyqtSignal() 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: 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 = PropertyEvaluationContext(self)
context.context["evaluate_from_container_index"] = _ContainerIndexes.Variant context.context["evaluate_from_container_index"] = _ContainerIndexes.Variant
@ -97,31 +99,35 @@ class ExtruderStack(CuraContainerStack):
approximateMaterialDiameterChanged = pyqtSignal() 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: 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()) return round(self.getCompatibleMaterialDiameter())
approximateMaterialDiameter = pyqtProperty(float, fget = getApproximateMaterialDiameter, approximateMaterialDiameter = pyqtProperty(float, fget = getApproximateMaterialDiameter,
notify = approximateMaterialDiameterChanged) 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) @override(ContainerStack)
def getProperty(self, key: str, property_name: str, context: Optional[PropertyEvaluationContext] = None) -> Any: 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: if not self._next_stack:
raise Exceptions.NoGlobalStackError("Extruder {id} is missing the next stack!".format(id = self.id)) raise Exceptions.NoGlobalStackError("Extruder {id} is missing the next stack!".format(id = self.id))

View file

@ -29,9 +29,9 @@ if TYPE_CHECKING:
from cura.Settings.ExtruderStack import ExtruderStack from cura.Settings.ExtruderStack import ExtruderStack
## Represents the Global or Machine stack and its related containers.
#
class GlobalStack(CuraContainerStack): class GlobalStack(CuraContainerStack):
"""Represents the Global or Machine stack and its related containers."""
def __init__(self, container_id: str) -> None: def __init__(self, container_id: str) -> None:
super().__init__(container_id) super().__init__(container_id)
@ -55,15 +55,19 @@ class GlobalStack(CuraContainerStack):
# properties. So we need to tie them together like this. # properties. So we need to tie them together like this.
self.metaDataChanged.connect(self.configuredConnectionTypesChanged) self.metaDataChanged.connect(self.configuredConnectionTypesChanged)
self.setDirty(False)
extrudersChanged = pyqtSignal() extrudersChanged = pyqtSignal()
configuredConnectionTypesChanged = pyqtSignal() configuredConnectionTypesChanged = pyqtSignal()
## Get the list of extruders of this stack.
#
# \return The extruders registered with this stack.
@pyqtProperty("QVariantMap", notify = extrudersChanged) @pyqtProperty("QVariantMap", notify = extrudersChanged)
@deprecated("Please use extruderList instead.", "4.4") @deprecated("Please use extruderList instead.", "4.4")
def extruders(self) -> Dict[str, "ExtruderStack"]: def extruders(self) -> Dict[str, "ExtruderStack"]:
"""Get the list of extruders of this stack.
:return: The extruders registered with this stack.
"""
return self._extruders return self._extruders
@pyqtProperty("QVariantList", notify = extrudersChanged) @pyqtProperty("QVariantList", notify = extrudersChanged)
@ -86,16 +90,18 @@ class GlobalStack(CuraContainerStack):
def getLoadingPriority(cls) -> int: def getLoadingPriority(cls) -> int:
return 2 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) @pyqtProperty("QVariantList", notify=configuredConnectionTypesChanged)
def configuredConnectionTypes(self) -> List[int]: 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). # 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) # 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(",") connection_types = self.getMetaDataEntry("connection_type", "").split(",")
@ -122,16 +128,18 @@ class GlobalStack(CuraContainerStack):
ConnectionType.CloudConnection.value] ConnectionType.CloudConnection.value]
return has_remote_connection return has_remote_connection
## \sa configuredConnectionTypes
def addConfiguredConnectionType(self, connection_type: int) -> None: def addConfiguredConnectionType(self, connection_type: int) -> None:
""":sa configuredConnectionTypes"""
configured_connection_types = self.configuredConnectionTypes configured_connection_types = self.configuredConnectionTypes
if connection_type not in configured_connection_types: if connection_type not in configured_connection_types:
# Store the values as a string. # Store the values as a string.
configured_connection_types.append(connection_type) configured_connection_types.append(connection_type)
self.setMetaDataEntry("connection_type", ",".join([str(c_type) for c_type in configured_connection_types])) self.setMetaDataEntry("connection_type", ",".join([str(c_type) for c_type in configured_connection_types]))
## \sa configuredConnectionTypes
def removeConfiguredConnectionType(self, connection_type: int) -> None: def removeConfiguredConnectionType(self, connection_type: int) -> None:
""":sa configuredConnectionTypes"""
configured_connection_types = self.configuredConnectionTypes configured_connection_types = self.configuredConnectionTypes
if connection_type in configured_connection_types: if connection_type in configured_connection_types:
# Store the values as a string. # Store the values as a string.
@ -163,13 +171,15 @@ class GlobalStack(CuraContainerStack):
def preferred_output_file_formats(self) -> str: def preferred_output_file_formats(self) -> str:
return self.getMetaDataEntry("file_formats") 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: 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") position = extruder.getMetaDataEntry("position")
if position is None: 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) Logger.log("w", "No position defined for extruder {extruder}, cannot add it to stack {stack}", extruder = extruder.id, stack = self.id)
@ -183,19 +193,21 @@ class GlobalStack(CuraContainerStack):
self.extrudersChanged.emit() self.extrudersChanged.emit()
Logger.log("i", "Extruder[%s] added to [%s] at position [%s]", extruder.id, self.id, position) 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) @override(ContainerStack)
def getProperty(self, key: str, property_name: str, context: Optional[PropertyEvaluationContext] = None) -> Any: 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): if not self.definition.findDefinitions(key = key):
return None return None
@ -235,11 +247,13 @@ class GlobalStack(CuraContainerStack):
context.popContainer() context.popContainer()
return result return result
## Overridden from ContainerStack
#
# This will simply raise an exception since the Global stack cannot have a next stack.
@override(ContainerStack) @override(ContainerStack)
def setNextStack(self, stack: CuraContainerStack, connect_signals: bool = True) -> None: def setNextStack(self, stack: CuraContainerStack, connect_signals: bool = True) -> None:
"""Overridden from ContainerStack
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!") raise Exceptions.InvalidOperationError("Global stack cannot have a next stack!")
# protected: # protected:
@ -267,9 +281,11 @@ class GlobalStack(CuraContainerStack):
return True 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: 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() container_registry = ContainerRegistry.getInstance()
extruder_trains = container_registry.findContainerStacks(type = "extruder_train", machine = self.getId()) extruder_trains = container_registry.findContainerStacks(type = "extruder_train", machine = self.getId())
@ -299,9 +315,10 @@ class GlobalStack(CuraContainerStack):
def hasVariantBuildplates(self) -> bool: def hasVariantBuildplates(self) -> bool:
return parseBool(self.getMetaDataEntry("has_variant_buildplates", False)) return parseBool(self.getMetaDataEntry("has_variant_buildplates", False))
## Get default firmware file name if one is specified in the firmware
@pyqtSlot(result = str) @pyqtSlot(result = str)
def getDefaultFirmwareName(self) -> 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") machine_has_heated_bed = self.getProperty("machine_heated_bed", "value")
baudrate = 250000 baudrate = 250000

View file

@ -2,7 +2,7 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot 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.Logger import Logger
from UM.Settings.InstanceContainer import InstanceContainer from UM.Settings.InstanceContainer import InstanceContainer
@ -15,29 +15,32 @@ if TYPE_CHECKING:
from UM.Settings.InstanceContainer import InstanceContainer from UM.Settings.InstanceContainer import InstanceContainer
## Front-end for querying which intents are available for a certain
# configuration.
class IntentManager(QObject): class IntentManager(QObject):
"""Front-end for querying which intents are available for a certain configuration.
"""
__instance = None __instance = None
## This class is a singleton.
@classmethod @classmethod
def getInstance(cls): def getInstance(cls):
"""This class is a singleton."""
if not cls.__instance: if not cls.__instance:
cls.__instance = IntentManager() cls.__instance = IntentManager()
return cls.__instance return cls.__instance
intentCategoryChanged = pyqtSignal() #Triggered when we switch categories. 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]]: 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]] intent_metadatas = [] # type: List[Dict[str, Any]]
try: try:
materials = ContainerTree.getInstance().machines[definition_id].variants[nozzle_name].materials materials = ContainerTree.getInstance().machines[definition_id].variants[nozzle_name].materials
@ -53,28 +56,32 @@ class IntentManager(QObject):
intent_metadatas.append(intent_node.getMetadata()) intent_metadatas.append(intent_node.getMetadata())
return intent_metadatas 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]: 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() categories = set()
for intent in self.intentMetadatas(definition_id, nozzle_id, material_id): for intent in self.intentMetadatas(definition_id, nozzle_id, material_id):
categories.add(intent["intent_category"]) 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. 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) 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]]: 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() application = cura.CuraApplication.CuraApplication.getInstance()
global_stack = application.getGlobalContainerStack() global_stack = application.getGlobalContainerStack()
if global_stack is None: if global_stack is None:
@ -100,16 +107,18 @@ class IntentManager(QObject):
result.add((intent_metadata["intent_category"], intent_metadata["quality_type"])) result.add((intent_metadata["intent_category"], intent_metadata["quality_type"]))
return list(result) 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]: 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() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if global_stack is None: if global_stack is None:
return ["default"] return ["default"]
@ -123,10 +132,12 @@ class IntentManager(QObject):
final_intent_categories.update(self.intentCategories(current_definition_id, nozzle_name, material_id)) final_intent_categories.update(self.intentCategories(current_definition_id, nozzle_name, material_id))
return list(final_intent_categories) 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": 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 return empty_intent_container
@pyqtProperty(str, notify = intentCategoryChanged) @pyqtProperty(str, notify = intentCategoryChanged)
@ -137,9 +148,10 @@ class IntentManager(QObject):
return "" return ""
return active_extruder_stack.intent.getMetaDataEntry("intent_category", "") return active_extruder_stack.intent.getMetaDataEntry("intent_category", "")
## Apply intent on the stacks.
@pyqtSlot(str, str) @pyqtSlot(str, str)
def selectIntent(self, intent_category: str, quality_type: str) -> None: 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) Logger.log("i", "Attempting to set intent_category to [%s] and quality type to [%s]", intent_category, quality_type)
old_intent_category = self.currentIntentCategory old_intent_category = self.currentIntentCategory
application = cura.CuraApplication.CuraApplication.getInstance() application = cura.CuraApplication.CuraApplication.getInstance()

View file

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
import time import time
@ -22,6 +22,7 @@ from UM.Settings.SettingFunction import SettingFunction
from UM.Signal import postponeSignals, CompressTechnique from UM.Signal import postponeSignals, CompressTechnique
import cura.CuraApplication # Imported like this to prevent circular references. import cura.CuraApplication # Imported like this to prevent circular references.
from UM.Util import parseBool
from cura.Machines.ContainerNode import ContainerNode from cura.Machines.ContainerNode import ContainerNode
from cura.Machines.ContainerTree import ContainerTree 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, from cura.Settings.cura_empty_instance_containers import (empty_definition_changes_container, empty_variant_container,
empty_material_container, empty_quality_container, empty_material_container, empty_quality_container,
empty_quality_changes_container, empty_intent_container) empty_quality_changes_container, empty_intent_container)
from cura.UltimakerCloud.UltimakerCloudConstants import META_UM_LINKED_TO_ACCOUNT
from .CuraStackBuilder import CuraStackBuilder from .CuraStackBuilder import CuraStackBuilder
@ -215,8 +217,9 @@ class MachineManager(QObject):
return set() return set()
return general_definition_containers[0].getAllKeys() return general_definition_containers[0].getAllKeys()
## Triggered when the global container stack is changed in CuraApplication.
def _onGlobalContainerChanged(self) -> None: def _onGlobalContainerChanged(self) -> None:
"""Triggered when the global container stack is changed in CuraApplication."""
if self._global_container_stack: if self._global_container_stack:
try: try:
self._global_container_stack.containersChanged.disconnect(self._onContainersChanged) self._global_container_stack.containersChanged.disconnect(self._onContainersChanged)
@ -287,9 +290,15 @@ class MachineManager(QObject):
self.activeStackValueChanged.emit() self.activeStackValueChanged.emit()
@pyqtSlot(str) @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. 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() container_registry = CuraContainerRegistry.getInstance()
containers = container_registry.findContainerStacks(id = stack_id) containers = container_registry.findContainerStacks(id = stack_id)
if not containers: if not containers:
@ -320,25 +329,33 @@ 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. # 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() extruder_manager.activeExtruderChanged.emit()
# Validate if the machine has the correct variants # Validate if the machine has the correct variants and materials.
# It can happen that a variant is empty, even though the machine has variants. This will ensure that that # 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 instead!) # 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()] machine_node = ContainerTree.getInstance().machines[global_stack.definition.getId()]
for extruder in self._global_container_stack.extruderList: for extruder in self._global_container_stack.extruderList:
variant_name = self._global_container_stack.variant.getName() variant_name = extruder.variant.getName()
variant_node = machine_node.variants.get(variant_name) variant_node = machine_node.variants.get(variant_name)
if variant_node is None: if variant_node is None:
Logger.log("w", "An extruder has an unknown variant, switching it to the preferred variant") 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) self.setVariantByName(extruder.getMetaDataEntry("position"), machine_node.preferred_variant_name)
self.__emitChangedSignals() 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")
self.setMaterialById(extruder.getMetaDataEntry("position"), 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 @staticmethod
def getMachine(definition_id: str, metadata_filter: Optional[Dict[str, str]] = None) -> Optional["GlobalStack"]: 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: if metadata_filter is None:
metadata_filter = {} metadata_filter = {}
machines = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine", **metadata_filter) machines = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine", **metadata_filter)
@ -392,9 +409,10 @@ class MachineManager(QObject):
Logger.log("d", "Checking %s stacks for errors took %.2f s" % (count, time.time() - time_start)) Logger.log("d", "Checking %s stacks for errors took %.2f s" % (count, time.time() - time_start))
return False return False
## Check if the global_container has instances in the user container
@pyqtProperty(bool, notify = activeStackValueChanged) @pyqtProperty(bool, notify = activeStackValueChanged)
def hasUserSettings(self) -> bool: def hasUserSettings(self) -> bool:
"""Check if the global_container has instances in the user container"""
if not self._global_container_stack: if not self._global_container_stack:
return False return False
@ -417,10 +435,12 @@ class MachineManager(QObject):
num_user_settings += stack.getTop().getNumInstances() num_user_settings += stack.getTop().getNumInstances()
return num_user_settings return 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) @pyqtSlot(str)
def clearUserSettingAllCurrentStacks(self, key: str) -> None: 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) Logger.log("i", "Clearing the setting [%s] from all stacks", key)
if not self._global_container_stack: if not self._global_container_stack:
return return
@ -449,11 +469,13 @@ class MachineManager(QObject):
for container in send_emits_containers: for container in send_emits_containers:
container.sendPostponedEmits() 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) @pyqtProperty(bool, notify = stacksValidationChanged)
def stacksHaveErrors(self) -> bool: 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) return bool(self._stacks_have_errors)
@pyqtProperty(str, notify = globalContainerChanged) @pyqtProperty(str, notify = globalContainerChanged)
@ -474,7 +496,15 @@ class MachineManager(QObject):
@pyqtProperty(bool, notify = printerConnectedStatusChanged) @pyqtProperty(bool, notify = printerConnectedStatusChanged)
def activeMachineIsGroup(self) -> bool: 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) @pyqtProperty(bool, notify = printerConnectedStatusChanged)
def activeMachineHasNetworkConnection(self) -> bool: def activeMachineHasNetworkConnection(self) -> bool:
@ -486,6 +516,10 @@ class MachineManager(QObject):
# A cloud connection is only available if any output device actually is a cloud connected device. # 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) 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) @pyqtProperty(bool, notify = printerConnectedStatusChanged)
def activeMachineIsUsingCloudConnection(self) -> bool: def activeMachineIsUsingCloudConnection(self) -> bool:
return self.activeMachineHasCloudConnection and not self.activeMachineHasNetworkConnection return self.activeMachineHasCloudConnection and not self.activeMachineHasNetworkConnection
@ -523,14 +557,16 @@ class MachineManager(QObject):
return material.getId() return material.getId()
return "" 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) @pyqtProperty(float, notify = activeQualityGroupChanged)
def activeQualityLayerHeight(self) -> float: 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: if not self._global_container_stack:
return 0 return 0
value = self._global_container_stack.getRawProperty("layer_height", "value", skip_until_container = self._global_container_stack.qualityChanges.getId()) value = self._global_container_stack.getRawProperty("layer_height", "value", skip_until_container = self._global_container_stack.qualityChanges.getId())
@ -600,13 +636,15 @@ class MachineManager(QObject):
return result 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) @pyqtProperty(bool, notify = activeQualityChanged)
def isCurrentSetupSupported(self) -> bool: 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: if not self._global_container_stack:
return False return False
for stack in [self._global_container_stack] + self._global_container_stack.extruderList: for stack in [self._global_container_stack] + self._global_container_stack.extruderList:
@ -617,9 +655,10 @@ class MachineManager(QObject):
return False return False
return True return True
## Copy the value of the setting of the current extruder to all other extruders as well as the global container.
@pyqtSlot(str) @pyqtSlot(str)
def copyValueToExtruders(self, key: str) -> None: 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: if self._active_container_stack is None or self._global_container_stack is None:
return return
new_value = self._active_container_stack.getProperty(key, "value") new_value = self._active_container_stack.getProperty(key, "value")
@ -629,9 +668,10 @@ class MachineManager(QObject):
if extruder_stack != self._active_container_stack and extruder_stack.getProperty(key, "value") != new_value: 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 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() @pyqtSlot()
def copyAllValuesToExtruders(self) -> None: 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: if self._active_container_stack is None or self._global_container_stack is None:
return return
@ -643,19 +683,23 @@ class MachineManager(QObject):
# Check if the value has to be replaced # Check if the value has to be replaced
extruder_stack.userChanges.setProperty(key, "value", new_value) 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) @pyqtProperty(str, notify = globalContainerChanged)
def activeQualityDefinitionId(self) -> str: def activeQualityDefinitionId(self) -> str:
"""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 = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if not global_stack: if not global_stack:
return "" return ""
return ContainerTree.getInstance().machines[global_stack.definition.getId()].quality_definition 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) @pyqtProperty(str, notify = globalContainerChanged)
def activeDefinitionVariantsName(self) -> str: 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") fallback_title = catalog.i18nc("@label", "Nozzle")
if self._global_container_stack: if self._global_container_stack:
return self._global_container_stack.definition.getMetaDataEntry("variants_name", fallback_title) return self._global_container_stack.definition.getMetaDataEntry("variants_name", fallback_title)
@ -683,26 +727,35 @@ class MachineManager(QObject):
other_machine_stacks = [s for s in machine_stacks if s["id"] != machine_id] other_machine_stacks = [s for s in machine_stacks if s["id"] != machine_id]
if other_machine_stacks: if other_machine_stacks:
self.setActiveMachine(other_machine_stacks[0]["id"]) 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) ExtruderManager.getInstance().removeMachineExtruders(machine_id)
containers = CuraContainerRegistry.getInstance().findInstanceContainersMetadata(type = "user", machine = machine_id) containers = CuraContainerRegistry.getInstance().findInstanceContainersMetadata(type = "user", machine = machine_id)
for container in containers: for container in containers:
CuraContainerRegistry.getInstance().removeContainer(container["id"]) 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) CuraContainerRegistry.getInstance().removeContainer(machine_id)
# If the printer that is being removed is a network printer, the hidden printers have to be also removed # 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) group_id = metadata.get("group_id", None)
if group_id: 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) hidden_containers = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine", **metadata_filter)
if hidden_containers: if hidden_containers:
# This reuses the method and remove all printers recursively # This reuses the method and remove all printers recursively
self.removeMachine(hidden_containers[0].getId()) 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) @pyqtProperty(bool, notify = activeMaterialChanged)
def variantBuildplateCompatible(self) -> bool: 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: if not self._global_container_stack:
return True return True
@ -719,10 +772,12 @@ class MachineManager(QObject):
return buildplate_compatible 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) @pyqtProperty(bool, notify = activeMaterialChanged)
def variantBuildplateUsable(self) -> bool: 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: if not self._global_container_stack:
return True return True
@ -743,11 +798,13 @@ class MachineManager(QObject):
return result 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) @pyqtSlot(str, result = str)
def getDefinitionByMachineId(self, machine_id: str) -> Optional[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) containers = CuraContainerRegistry.getInstance().findContainerStacks(id = machine_id)
if containers: if containers:
return containers[0].definition.getId() return containers[0].definition.getId()
@ -778,8 +835,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) Logger.log("d", "Reset setting [%s] in [%s] because its old value [%s] is no longer valid", setting_key, container, old_value)
return result 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: 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: if self._global_container_stack is None:
return return
for setting_key in self.getIncompatibleSettingsOnEnabledExtruders(self._global_container_stack.userChanges): for setting_key in self.getIncompatibleSettingsOnEnabledExtruders(self._global_container_stack.userChanges):
@ -795,9 +853,11 @@ class MachineManager(QObject):
title = catalog.i18nc("@info:title", "Settings updated")) title = catalog.i18nc("@info:title", "Settings updated"))
caution_message.show() 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 setActiveMachineExtruderCount(self, extruder_count: int) -> None: 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: if self._global_container_stack is None:
return return
extruder_manager = self._application.getExtruderManager() extruder_manager = self._application.getExtruderManager()
@ -894,9 +954,10 @@ class MachineManager(QObject):
def defaultExtruderPosition(self) -> str: def defaultExtruderPosition(self) -> str:
return self._default_extruder_position return self._default_extruder_position
## This will fire the propertiesChanged for all settings so they will be updated in the front-end
@pyqtSlot() @pyqtSlot()
def forceUpdateAllSettings(self) -> None: 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: if self._global_container_stack is None:
return return
with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue): with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue):
@ -907,7 +968,7 @@ class MachineManager(QObject):
@pyqtSlot(int, bool) @pyqtSlot(int, bool)
def setExtruderEnabled(self, position: int, enabled: bool) -> None: 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) Logger.log("w", "Could not find extruder on position %s.", position)
return return
extruder = self._global_container_stack.extruderList[position] extruder = self._global_container_stack.extruderList[position]
@ -937,11 +998,13 @@ class MachineManager(QObject):
def _onMaterialNameChanged(self) -> None: def _onMaterialNameChanged(self) -> None:
self.activeMaterialChanged.emit() 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]: 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: if self._global_container_stack is None:
return [] return []
return [s.containersChanged for s in self._global_container_stack.extruderList + [self._global_container_stack]] return [s.containersChanged for s in self._global_container_stack.extruderList + [self._global_container_stack]]
@ -954,18 +1017,21 @@ class MachineManager(QObject):
container = extruder.userChanges container = extruder.userChanges
container.setProperty(setting_name, property_name, property_value) 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) @pyqtSlot(str)
def resetSettingForAllExtruders(self, setting_name: str) -> None: 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: if self._global_container_stack is None:
return return
for extruder in self._global_container_stack.extruderList: for extruder in self._global_container_stack.extruderList:
container = extruder.userChanges container = extruder.userChanges
container.removeInstance(setting_name) container.removeInstance(setting_name)
## Update _current_root_material_id when the current root material was changed.
def _onRootMaterialChanged(self) -> None: def _onRootMaterialChanged(self) -> None:
"""Update _current_root_material_id when the current root material was changed."""
self._current_root_material_id = {} self._current_root_material_id = {}
changed = False changed = False
@ -1065,14 +1131,14 @@ class MachineManager(QObject):
self._global_container_stack.quality = quality_container self._global_container_stack.quality = quality_container
self._global_container_stack.qualityChanges = quality_changes_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 quality_node = None
if quality_group is not 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_changes_container = empty_quality_changes_container
quality_container = empty_quality_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: if quality_changes_metadata:
containers = container_registry.findContainers(id = quality_changes_metadata["id"]) containers = container_registry.findContainers(id = quality_changes_metadata["id"])
if containers: if containers:
@ -1091,7 +1157,7 @@ class MachineManager(QObject):
def _setVariantNode(self, position: str, variant_node: "VariantNode") -> None: def _setVariantNode(self, position: str, variant_node: "VariantNode") -> None:
if self._global_container_stack is None: if self._global_container_stack is None:
return 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() self.activeVariantChanged.emit()
def _setGlobalVariant(self, container_node: "ContainerNode") -> None: def _setGlobalVariant(self, container_node: "ContainerNode") -> None:
@ -1106,7 +1172,7 @@ class MachineManager(QObject):
return return
if material_node and material_node.container: if material_node and material_node.container:
material_container = 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) root_material_id = material_container.getMetaDataEntry("base_file", None)
else: else:
self._global_container_stack.extruderList[int(position)].material = empty_material_container self._global_container_stack.extruderList[int(position)].material = empty_material_container
@ -1127,8 +1193,9 @@ class MachineManager(QObject):
return False return False
return True return True
## Update current quality type and machine after setting material
def _updateQualityWithMaterial(self, *args: Any) -> None: def _updateQualityWithMaterial(self, *args: Any) -> None:
"""Update current quality type and machine after setting material"""
global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if global_stack is None: if global_stack is None:
return return
@ -1145,7 +1212,6 @@ class MachineManager(QObject):
return return
if not available_quality_types: 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).") Logger.log("i", "No available quality types found, setting all qualities to empty (Not Supported).")
self._setEmptyQuality() self._setEmptyQuality()
return return
@ -1169,8 +1235,9 @@ class MachineManager(QObject):
current_quality_type, quality_type) current_quality_type, quality_type)
self._setQualityGroup(candidate_quality_groups[quality_type], empty_quality_changes = True) self._setQualityGroup(candidate_quality_groups[quality_type], empty_quality_changes = True)
## Update the current intent after the quality changed
def _updateIntentWithQuality(self): def _updateIntentWithQuality(self):
"""Update the current intent after the quality changed"""
global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if global_stack is None: if global_stack is None:
return return
@ -1197,16 +1264,18 @@ class MachineManager(QObject):
category = current_category category = current_category
self.setIntentByCategory(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() @pyqtSlot()
def updateMaterialWithVariant(self, position: Optional[str] = None) -> None: 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: if self._global_container_stack is None:
return return
if position is None: 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: else:
position_list = [position] position_list = [position]
@ -1237,16 +1306,22 @@ class MachineManager(QObject):
material_node = nozzle_node.preferredMaterial(approximate_material_diameter) material_node = nozzle_node.preferredMaterial(approximate_material_diameter)
self._setMaterial(position_item, material_node) 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) @pyqtSlot(str)
def switchPrinterType(self, machine_name: str) -> None: 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 # 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: if self._global_container_stack is None or self._global_container_stack.definition.name == machine_name:
return return
Logger.log("i", "Attempting to switch the printer type to [%s]", machine_name) Logger.log("i", "Attempting to switch the printer type to [%s]", machine_name)
# Get the definition id corresponding to this 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 # Try to find a machine with the same network key
metadata_filter = {"group_id": self._global_container_stack.getMetaDataEntry("group_id")} metadata_filter = {"group_id": self._global_container_stack.getMetaDataEntry("group_id")}
new_machine = self.getMachine(machine_definition_id, metadata_filter = metadata_filter) new_machine = self.getMachine(machine_definition_id, metadata_filter = metadata_filter)
@ -1274,17 +1349,15 @@ class MachineManager(QObject):
# Keep a temporary copy of the global and per-extruder user changes and transfer them to the user changes # 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. # of the new machine after the new_machine becomes active.
global_user_changes = self._global_container_stack.userChanges global_user_changes = self._global_container_stack.userChanges
per_extruder_user_changes = {} per_extruder_user_changes = [extruder_stack.userChanges for extruder_stack in self._global_container_stack.extruderList]
for extruder_name, extruder_stack in self._global_container_stack.extruders.items():
per_extruder_user_changes[extruder_name] = extruder_stack.userChanges
self.setActiveMachine(new_machine.getId()) self.setActiveMachine(new_machine.getId())
# Apply the global and per-extruder userChanges to the new_machine (which is of different type than the # Apply the global and per-extruder userChanges to the new_machine (which is of different type than the
# previous one). # previous one).
self._global_container_stack.setUserChanges(global_user_changes) self._global_container_stack.setUserChanges(global_user_changes)
for extruder_name in self._global_container_stack.extruders.keys(): for i, user_changes in enumerate(per_extruder_user_changes):
self._global_container_stack.extruders[extruder_name].setUserChanges(per_extruder_user_changes[extruder_name]) self._global_container_stack.extruderList[i].setUserChanges(per_extruder_user_changes[i])
@pyqtSlot(QObject) @pyqtSlot(QObject)
def applyRemoteConfiguration(self, configuration: PrinterConfigurationModel) -> None: def applyRemoteConfiguration(self, configuration: PrinterConfigurationModel) -> None:
@ -1295,7 +1368,6 @@ class MachineManager(QObject):
with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue): with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue):
self.switchPrinterType(configuration.printerType) self.switchPrinterType(configuration.printerType)
disabled_used_extruder_position_set = set()
extruders_to_disable = 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 # If an extruder that's currently used to print a model gets disabled due to the syncing, we need to show
@ -1304,8 +1376,8 @@ class MachineManager(QObject):
for extruder_configuration in configuration.extruderConfigurations: for extruder_configuration in configuration.extruderConfigurations:
# We support "" or None, since the cloud uses None instead of empty strings # 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_hotend = extruder_configuration.hotendID not in ["", None]
extruder_has_material = extruder_configuration.material.guid and extruder_configuration.material.guid != "" 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 the machine doesn't have a hotend or material, disable this extruder
if not extruder_has_hotend or not extruder_has_material: if not extruder_has_hotend or not extruder_has_material:
@ -1323,7 +1395,6 @@ class MachineManager(QObject):
self._global_container_stack.extruderList[int(position)].setEnabled(False) self._global_container_stack.extruderList[int(position)].setEnabled(False)
need_to_show_message = True need_to_show_message = True
disabled_used_extruder_position_set.add(int(position))
else: else:
machine_node = ContainerTree.getInstance().machines.get(self._global_container_stack.definition.getId()) machine_node = ContainerTree.getInstance().machines.get(self._global_container_stack.definition.getId())
@ -1342,7 +1413,7 @@ class MachineManager(QObject):
material_container_node = variant_node.materials.get(base_file, material_container_node) material_container_node = variant_node.materials.get(base_file, material_container_node)
self._setMaterial(position, 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.updateMaterialWithVariant(position)
self.updateDefaultExtruder() self.updateDefaultExtruder()
@ -1354,7 +1425,7 @@ class MachineManager(QObject):
# Show human-readable extruder names such as "Extruder Left", "Extruder Front" instead of "Extruder 1, 2, 3". # Show human-readable extruder names such as "Extruder Left", "Extruder Front" instead of "Extruder 1, 2, 3".
extruder_names = [] 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_stack = self._global_container_stack.extruderList[int(extruder_position)]
extruder_name = extruder_stack.definition.getName() extruder_name = extruder_stack.definition.getName()
extruder_names.append(extruder_name) extruder_names.append(extruder_name)
@ -1388,12 +1459,14 @@ class MachineManager(QObject):
material_node = ContainerTree.getInstance().machines[machine_definition_id].variants[nozzle_name].materials[root_material_id] material_node = ContainerTree.getInstance().machines[machine_definition_id].variants[nozzle_name].materials[root_material_id]
self.setMaterial(position, material_node) 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.
@pyqtSlot(str, "QVariant") @pyqtSlot(str, "QVariant")
def setMaterial(self, position: str, container_node, global_stack: Optional["GlobalStack"] = None) -> None: 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: 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 return
position = str(position) position = str(position)
self.blurSettings.emit() self.blurSettings.emit()
@ -1412,6 +1485,9 @@ class MachineManager(QObject):
machine_definition_id = self._global_container_stack.definition.id machine_definition_id = self._global_container_stack.definition.id
machine_node = ContainerTree.getInstance().machines.get(machine_definition_id) machine_node = ContainerTree.getInstance().machines.get(machine_definition_id)
variant_node = machine_node.variants.get(variant_name) variant_node = machine_node.variants.get(variant_name)
if variant_node is None:
Logger.error("There is no variant with the name {variant_name}.")
return
self.setVariant(position, variant_node) self.setVariant(position, variant_node)
@pyqtSlot(str, "QVariant") @pyqtSlot(str, "QVariant")
@ -1434,10 +1510,12 @@ class MachineManager(QObject):
# Get all the quality groups for this global stack and filter out by quality_type # Get all the quality groups for this global stack and filter out by quality_type
self.setQualityGroup(ContainerTree.getInstance().getCurrentQualityGroups()[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) @pyqtSlot(QObject)
def setQualityGroup(self, quality_group: "QualityGroup", no_dialog: bool = False, global_stack: Optional["GlobalStack"] = None) -> None: 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 global_stack is not None and global_stack != self._global_container_stack:
if quality_group is None: if quality_group is None:
Logger.log("e", "Could not set quality group because quality group is None") Logger.log("e", "Could not set quality group because quality group is None")
@ -1499,15 +1577,17 @@ class MachineManager(QObject):
return {"main": main_part, return {"main": main_part,
"suffix": suffix_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) @pyqtSlot(str)
def setIntentByCategory(self, intent_category: str) -> None: def setIntentByCategory(self, intent_category: str) -> None:
"""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 = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if global_stack is None: if global_stack is None:
return return
@ -1539,21 +1619,25 @@ class MachineManager(QObject):
else: # No intent had the correct category. else: # No intent had the correct category.
extruder.intent = empty_intent_container 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"]: def activeQualityGroup(self) -> Optional["QualityGroup"]:
"""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 = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if not global_stack or global_stack.quality == empty_quality_container: if not global_stack or global_stack.quality == empty_quality_container:
return None return None
return ContainerTree.getInstance().getCurrentQualityGroups().get(self.activeQualityType) 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) @pyqtProperty(str, notify = activeQualityGroupChanged)
def activeQualityGroupName(self) -> str: def activeQualityGroupName(self) -> str:
"""Get the name of the active quality group.
:return: The name of the active quality group.
"""
quality_group = self.activeQualityGroup() quality_group = self.activeQualityGroup()
if quality_group is None: if quality_group is None:
return "" return ""
@ -1575,7 +1659,7 @@ class MachineManager(QObject):
return return
with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue): with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue):
self._setQualityGroup(self.activeQualityGroup()) 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() stack.userChanges.clear()
@pyqtProperty(QObject, fset = setQualityChangesGroup, notify = activeQualityChangesGroupChanged) @pyqtProperty(QObject, fset = setQualityChangesGroup, notify = activeQualityChangesGroupChanged)
@ -1626,9 +1710,10 @@ class MachineManager(QObject):
self.updateMaterialWithVariant(None) self.updateMaterialWithVariant(None)
self._updateQualityWithMaterial() self._updateQualityWithMaterial()
## This function will translate any printer type name to an abbreviated printer type name
@pyqtSlot(str, result = str) @pyqtSlot(str, result = str)
def getAbbreviatedMachineName(self, machine_type_name: str) -> 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 = "" abbr_machine = ""
for word in re.findall(r"[\w']+", machine_type_name): for word in re.findall(r"[\w']+", machine_type_name):
if word.lower() == "ultimaker": if word.lower() == "ultimaker":

View file

@ -10,10 +10,13 @@ from UM.Resources import Resources
from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.Settings.InstanceContainer import InstanceContainer from UM.Settings.InstanceContainer import InstanceContainer
## Are machine names valid?
#
# Performs checks based on the length of the name.
class MachineNameValidator(QObject): class MachineNameValidator(QObject):
"""Are machine names valid?
Performs checks based on the length of the name.
"""
def __init__(self, parent = None): def __init__(self, parent = None):
super().__init__(parent) super().__init__(parent)
@ -32,12 +35,13 @@ class MachineNameValidator(QObject):
validationChanged = pyqtSignal() 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): 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). #Check for file name length of the current settings container (which is the longest file we're saving with the name).
try: try:
filename_max_length = os.statvfs(Resources.getDataStoragePath()).f_namemax filename_max_length = os.statvfs(Resources.getDataStoragePath()).f_namemax
@ -50,9 +54,10 @@ class MachineNameValidator(QObject):
return QValidator.Acceptable #All checks succeeded. return QValidator.Acceptable #All checks succeeded.
## Updates the validation state of a machine name text field.
@pyqtSlot(str) @pyqtSlot(str)
def updateValidation(self, new_name): def updateValidation(self, new_name):
"""Updates the validation state of a machine name text field."""
is_valid = self.validate(new_name) is_valid = self.validate(new_name)
if is_valid == QValidator.Acceptable: if is_valid == QValidator.Acceptable:
self.validation_regex = "^.*$" #Matches anything. self.validation_regex = "^.*$" #Matches anything.

View file

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Any, Optional from typing import Any, Optional
@ -45,13 +45,13 @@ class PerObjectContainerStack(CuraContainerStack):
if "original_limit_to_extruder" in context.context: if "original_limit_to_extruder" in context.context:
limit_to_extruder = context.context["original_limit_to_extruder"] 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 # 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: if "original_limit_to_extruder" not in context.context:
context.context["original_limit_to_extruder"] = limit_to_extruder context.context["original_limit_to_extruder"] = limit_to_extruder
if super().getProperty(key, "settable_per_extruder", context): 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: if result is not None:
context.popContainer() context.popContainer()
return result return result

View file

@ -6,8 +6,10 @@ from UM.Operations.Operation import Operation
from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator
## Simple operation to set the extruder a certain object should be printed with.
class SetObjectExtruderOperation(Operation): 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: def __init__(self, node: SceneNode, extruder_id: str) -> None:
self._node = node self._node = node
self._extruder_id = extruder_id self._extruder_id = extruder_id

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