Merge remote-tracking branch 'origin/master' into WIP_kill_extruder_manager

This commit is contained in:
Lipu Fei 2018-10-03 15:45:18 +02:00
commit 18cb21186c
129 changed files with 3708 additions and 2220 deletions

117
cura/API/Account.py Normal file
View file

@ -0,0 +1,117 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, Dict, TYPE_CHECKING
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty
from UM.i18n import i18nCatalog
from UM.Message import Message
from cura.OAuth2.AuthorizationService import AuthorizationService
from cura.OAuth2.Models import OAuth2Settings
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
i18n_catalog = i18nCatalog("cura")
## The account API provides a version-proof bridge to use Ultimaker Accounts
#
# Usage:
# ``from cura.API import CuraAPI
# api = CuraAPI()
# api.account.login()
# api.account.logout()
# api.account.userProfile # Who is logged in``
#
class Account(QObject):
# Signal emitted when user logged in or out.
loginStateChanged = pyqtSignal(bool)
def __init__(self, application: "CuraApplication", parent = None) -> None:
super().__init__(parent)
self._application = application
self._error_message = None # type: Optional[Message]
self._logged_in = False
self._callback_port = 32118
self._oauth_root = "https://account.ultimaker.com"
self._cloud_api_root = "https://api.ultimaker.com"
self._oauth_settings = OAuth2Settings(
OAUTH_SERVER_URL= self._oauth_root,
CALLBACK_PORT=self._callback_port,
CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port),
CLIENT_ID="um---------------ultimaker_cura_drive_plugin",
CLIENT_SCOPES="user.read drive.backups.read drive.backups.write",
AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data",
AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root),
AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root)
)
self._authorization_service = AuthorizationService(self._oauth_settings)
def initialize(self) -> None:
self._authorization_service.initialize(self._application.getPreferences())
self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged)
self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged)
self._authorization_service.loadAuthDataFromPreferences()
@pyqtProperty(bool, notify=loginStateChanged)
def isLoggedIn(self) -> bool:
return self._logged_in
def _onLoginStateChanged(self, logged_in: bool = False, error_message: Optional[str] = None) -> None:
if error_message:
if self._error_message:
self._error_message.hide()
self._error_message = Message(error_message, title = i18n_catalog.i18nc("@info:title", "Login failed"))
self._error_message.show()
if self._logged_in != logged_in:
self._logged_in = logged_in
self.loginStateChanged.emit(logged_in)
@pyqtSlot()
def login(self) -> None:
if self._logged_in:
# Nothing to do, user already logged in.
return
self._authorization_service.startAuthorizationFlow()
@pyqtProperty(str, notify=loginStateChanged)
def userName(self):
user_profile = self._authorization_service.getUserProfile()
if not user_profile:
return None
return user_profile.username
@pyqtProperty(str, notify = loginStateChanged)
def profileImageUrl(self):
user_profile = self._authorization_service.getUserProfile()
if not user_profile:
return None
return user_profile.profile_image_url
@pyqtProperty(str, notify=loginStateChanged)
def accessToken(self) -> Optional[str]:
return self._authorization_service.getAccessToken()
# Get the profile of the logged in user
# @returns None if no user is logged in, a dict containing user_id, username and profile_image_url
@pyqtProperty("QVariantMap", notify = loginStateChanged)
def userProfile(self) -> Optional[Dict[str, Optional[str]]]:
user_profile = self._authorization_service.getUserProfile()
if not user_profile:
return None
return user_profile.__dict__
@pyqtSlot()
def logout(self) -> None:
if not self._logged_in:
return # Nothing to do, user isn't logged in.
self._authorization_service.deleteAuthData()

View file

@ -1,9 +1,12 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Tuple, Optional
from typing import Tuple, Optional, TYPE_CHECKING
from cura.Backups.BackupsManager import BackupsManager
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
## The back-ups API provides a version-proof bridge between Cura's
# BackupManager and plug-ins that hook into it.
@ -13,9 +16,10 @@ from cura.Backups.BackupsManager import BackupsManager
# api = CuraAPI()
# api.backups.createBackup()
# api.backups.restoreBackup(my_zip_file, {"cura_release": "3.1"})``
class Backups:
manager = BackupsManager() # Re-used instance of the backups manager.
def __init__(self, application: "CuraApplication") -> None:
self.manager = BackupsManager(application)
## Create a new back-up using the BackupsManager.
# \return Tuple containing a ZIP file with the back-up data and a dict

View file

@ -1,7 +1,11 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from cura.CuraApplication import CuraApplication
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
## The Interface.Settings API provides a version-proof bridge between Cura's
# (currently) sidebar UI and plug-ins that hook into it.
@ -19,8 +23,9 @@ from cura.CuraApplication import CuraApplication
# api.interface.settings.addContextMenuItem(data)``
class Settings:
# Re-used instance of Cura:
application = CuraApplication.getInstance() # type: CuraApplication
def __init__(self, application: "CuraApplication") -> None:
self.application = application
## Add items to the sidebar context menu.
# \param menu_item dict containing the menu item to add.
@ -30,4 +35,4 @@ class Settings:
## Get all custom items currently added to the sidebar context menu.
# \return List containing all custom context menu items.
def getContextMenuItems(self) -> list:
return self.application.getSidebarCustomMenuItems()
return self.application.getSidebarCustomMenuItems()

View file

@ -1,9 +1,15 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import TYPE_CHECKING
from UM.PluginRegistry import PluginRegistry
from cura.API.Interface.Settings import Settings
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
## The Interface class serves as a common root for the specific API
# methods for each interface element.
#
@ -20,5 +26,6 @@ class Interface:
# For now we use the same API version to be consistent.
VERSION = PluginRegistry.APIVersion
# API methods specific to the settings portion of the UI
settings = Settings()
def __init__(self, application: "CuraApplication") -> None:
# API methods specific to the settings portion of the UI
self.settings = Settings(application)

View file

@ -1,8 +1,17 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, TYPE_CHECKING
from PyQt5.QtCore import QObject, pyqtProperty
from UM.PluginRegistry import PluginRegistry
from cura.API.Backups import Backups
from cura.API.Interface import Interface
from cura.API.Account import Account
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
## The official Cura API that plug-ins can use to interact with Cura.
#
@ -10,14 +19,47 @@ from cura.API.Interface import Interface
# 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:
class CuraAPI(QObject):
# For now we use the same API version to be consistent.
VERSION = PluginRegistry.APIVersion
__instance = None # type: "CuraAPI"
_application = None # type: CuraApplication
# Backups API
backups = Backups()
# This is done to ensure that the first time an instance is created, it's forced that the application is set.
# The main reason for this is that we want to prevent consumers of API to have a dependency on CuraApplication.
# Since the API is intended to be used by plugins, the cura application should have already created this.
def __new__(cls, application: Optional["CuraApplication"] = None):
if cls.__instance is None:
if application is None:
raise Exception("Upon first time creation, the application must be set.")
cls.__instance = super(CuraAPI, cls).__new__(cls)
cls._application = application
return cls.__instance
# Interface API
interface = Interface()
def __init__(self, application: Optional["CuraApplication"] = None) -> None:
super().__init__(parent = CuraAPI._application)
# Accounts API
self._account = Account(self._application)
# Backups API
self._backups = Backups(self._application)
# Interface API
self._interface = Interface(self._application)
def initialize(self) -> None:
self._account.initialize()
@pyqtProperty(QObject, constant = True)
def account(self) -> "Account":
return self._account
@property
def backups(self) -> "Backups":
return self._backups
@property
def interface(self) -> "Interface":
return self._interface

View file

@ -4,18 +4,18 @@
import io
import os
import re
import shutil
from typing import Dict, Optional
from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile
from typing import Dict, Optional, TYPE_CHECKING
from UM import i18nCatalog
from UM.Logger import Logger
from UM.Message import Message
from UM.Platform import Platform
from UM.Resources import Resources
from cura.CuraApplication import CuraApplication
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
## The back-up class holds all data about a back-up.
@ -29,24 +29,25 @@ class Backup:
# Re-use translation catalog.
catalog = i18nCatalog("cura")
def __init__(self, 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.zip_file = zip_file # type: Optional[bytes]
self.meta_data = meta_data # type: Optional[Dict[str, str]]
## Create a back-up from the current user config folder.
def makeFromCurrent(self) -> None:
cura_release = CuraApplication.getInstance().getVersion()
cura_release = self._application.getVersion()
version_data_dir = Resources.getDataStoragePath()
Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir)
# Ensure all current settings are saved.
CuraApplication.getInstance().saveSettings()
self._application.saveSettings()
# We copy the preferences file to the user data directory in Linux as it's in a different location there.
# When restoring a backup on Linux, we move it back.
if Platform.isLinux():
preferences_file_name = CuraApplication.getInstance().getApplicationName()
preferences_file_name = self._application.getApplicationName()
preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name))
backup_preferences_file = os.path.join(version_data_dir, "{}.cfg".format(preferences_file_name))
Logger.log("d", "Copying preferences file from %s to %s", preferences_file, backup_preferences_file)
@ -58,7 +59,7 @@ class Backup:
if archive is None:
return
files = archive.namelist()
# Count the metadata items. We do this in a rather naive way at the moment.
machine_count = len([s for s in files if "machine_instances/" in s]) - 1
material_count = len([s for s in files if "materials/" in s]) - 1
@ -112,7 +113,7 @@ class Backup:
"Tried to restore a Cura backup without having proper data or meta data."))
return False
current_version = CuraApplication.getInstance().getVersion()
current_version = self._application.getVersion()
version_to_restore = self.meta_data.get("cura_release", "master")
if current_version != version_to_restore:
# Cannot restore version older or newer than current because settings might have changed.
@ -128,7 +129,7 @@ class Backup:
# Under Linux, preferences are stored elsewhere, so we copy the file to there.
if Platform.isLinux():
preferences_file_name = CuraApplication.getInstance().getApplicationName()
preferences_file_name = self._application.getApplicationName()
preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name))
backup_preferences_file = os.path.join(version_data_dir, "{}.cfg".format(preferences_file_name))
Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file)

View file

@ -1,11 +1,13 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Dict, Optional, Tuple
from typing import Dict, Optional, Tuple, TYPE_CHECKING
from UM.Logger import Logger
from cura.Backups.Backup import Backup
from cura.CuraApplication import CuraApplication
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
## The BackupsManager is responsible for managing the creating and restoring of
@ -13,15 +15,15 @@ from cura.CuraApplication import CuraApplication
#
# Back-ups themselves are represented in a different class.
class BackupsManager:
def __init__(self):
self._application = CuraApplication.getInstance()
def __init__(self, application: "CuraApplication") -> None:
self._application = application
## Get a back-up of the current configuration.
# \return A tuple containing a ZipFile (the actual back-up) and a dict
# containing some metadata (like version).
def createBackup(self) -> Tuple[Optional[bytes], Optional[Dict[str, str]]]:
self._disableAutoSave()
backup = Backup()
backup = Backup(self._application)
backup.makeFromCurrent()
self._enableAutoSave()
# We don't return a Backup here because we want plugins only to interact with our API and not full objects.
@ -39,7 +41,7 @@ class BackupsManager:
self._disableAutoSave()
backup = Backup(zip_file = zip_file, meta_data = meta_data)
backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data)
restored = backup.restore()
if restored:
# At this point, Cura will need to restart for the changes to take effect.

View file

@ -479,8 +479,6 @@ class BuildVolume(SceneNode):
maximum = Vector(max_w - bed_adhesion_size - 1, max_h - self._raft_thickness - self._extra_z_clearance, max_d - disallowed_area_size + bed_adhesion_size - 1)
)
self._application.getController().getScene()._maximum_bounds = scale_to_max_bounds
self.updateNodeBoundaryCheck()
def getBoundingBox(self) -> AxisAlignedBox:

View file

@ -44,6 +44,7 @@ from UM.Operations.RemoveSceneNodeOperation import RemoveSceneNodeOperation
from UM.Operations.GroupedOperation import GroupedOperation
from UM.Operations.SetTransformOperation import SetTransformOperation
from cura.API import CuraAPI
from cura.Arranging.Arrange import Arrange
from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob
from cura.Arranging.ArrangeObjectsAllBuildPlatesJob import ArrangeObjectsAllBuildPlatesJob
@ -61,6 +62,7 @@ from cura.Scene.CuraSceneController import CuraSceneController
from UM.Settings.SettingDefinition import SettingDefinition, DefinitionPropertyType
from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.Settings.SettingFunction import SettingFunction
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
from cura.Settings.MachineNameValidator import MachineNameValidator
from cura.Machines.Models.BuildPlateModel import BuildPlateModel
@ -206,6 +208,7 @@ class CuraApplication(QtApplication):
self._quality_profile_drop_down_menu_model = None
self._custom_quality_profile_drop_down_menu_model = None
self._cura_API = CuraAPI(self)
self._physics = None
self._volume = None
@ -244,6 +247,8 @@ class CuraApplication(QtApplication):
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
self._container_registry_class = CuraContainerRegistry
# Redefined here in order to please the typing.
self._container_registry = None # type: CuraContainerRegistry
from cura.CuraPackageManager import CuraPackageManager
self._package_manager_class = CuraPackageManager
@ -268,6 +273,9 @@ class CuraApplication(QtApplication):
help = "FOR TESTING ONLY. Trigger an early crash to show the crash dialog.")
self._cli_parser.add_argument("file", nargs = "*", help = "Files to load after starting the application.")
def getContainerRegistry(self) -> "CuraContainerRegistry":
return self._container_registry
def parseCliOptions(self):
super().parseCliOptions()
@ -679,7 +687,7 @@ class CuraApplication(QtApplication):
Logger.log("i", "Initializing quality manager")
from cura.Machines.QualityManager import QualityManager
self._quality_manager = QualityManager(container_registry, parent = self)
self._quality_manager = QualityManager(self, parent = self)
self._quality_manager.initialize()
Logger.log("i", "Initializing machine manager")
@ -711,6 +719,9 @@ class CuraApplication(QtApplication):
default_visibility_profile = self._setting_visibility_presets_model.getItem(0)
self.getPreferences().setDefault("general/visible_settings", ";".join(default_visibility_profile["settings"]))
# Initialize Cura API
self._cura_API.initialize()
# Detect in which mode to run and execute that mode
if self._is_headless:
self.runWithoutGUI()
@ -903,6 +914,9 @@ class CuraApplication(QtApplication):
self._custom_quality_profile_drop_down_menu_model = CustomQualityProfilesDropDownMenuModel(self)
return self._custom_quality_profile_drop_down_menu_model
def getCuraAPI(self, *args, **kwargs) -> "CuraAPI":
return self._cura_API
## Registers objects for the QML engine to use.
#
# \param engine The QML engine.
@ -951,6 +965,9 @@ class CuraApplication(QtApplication):
qmlRegisterSingletonType(ContainerManager, "Cura", 1, 0, "ContainerManager", ContainerManager.getInstance)
qmlRegisterType(SidebarCustomMenuItemsModel, "Cura", 1, 0, "SidebarCustomMenuItemsModel")
from cura.API import CuraAPI
qmlRegisterSingletonType(CuraAPI, "Cura", 1, 1, "API", self.getCuraAPI)
# 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")))
qmlRegisterSingletonType(actions_url, "Cura", 1, 0, "Actions")
@ -1590,6 +1607,11 @@ class CuraApplication(QtApplication):
job.start()
def _readMeshFinished(self, job):
global_container_stack = self.getGlobalContainerStack()
if not global_container_stack:
Logger.log("w", "Can't load meshes before a printer is added.")
return
nodes = job.getResult()
file_name = job.getFileName()
file_name_lower = file_name.lower()
@ -1604,7 +1626,6 @@ class CuraApplication(QtApplication):
for node_ in DepthFirstIterator(root):
if node_.callDecoration("isSliceable") and node_.callDecoration("getBuildPlateNumber") == target_build_plate:
fixed_nodes.append(node_)
global_container_stack = self.getGlobalContainerStack()
machine_width = global_container_stack.getProperty("machine_width", "value")
machine_depth = global_container_stack.getProperty("machine_depth", "value")
arranger = Arrange.create(x = machine_width, y = machine_depth, fixed_nodes = fixed_nodes)

View file

@ -9,9 +9,6 @@ from UM.ConfigurationErrorMessage import ConfigurationErrorMessage
from UM.Logger import Logger
from UM.Settings.InstanceContainer import InstanceContainer
if TYPE_CHECKING:
from cura.Machines.QualityGroup import QualityGroup
##
# A metadata / container combination. Use getContainer() to get the container corresponding to the metadata.
@ -24,11 +21,11 @@ if TYPE_CHECKING:
# This is used in Variant, Material, and Quality Managers.
#
class ContainerNode:
__slots__ = ("_metadata", "container", "children_map")
__slots__ = ("_metadata", "_container", "children_map")
def __init__(self, metadata: Optional[Dict[str, Any]] = None) -> None:
self._metadata = metadata
self.container = None
self._container = None # type: Optional[InstanceContainer]
self.children_map = OrderedDict() # type: ignore # This is because it's children are supposed to override it.
## Get an entry value from the metadata
@ -50,7 +47,7 @@ class ContainerNode:
Logger.log("e", "Cannot get container for a ContainerNode without metadata.")
return None
if self.container is None:
if self._container is None:
container_id = self._metadata["id"]
from UM.Settings.ContainerRegistry import ContainerRegistry
container_list = ContainerRegistry.getInstance().findInstanceContainers(id = container_id)
@ -59,9 +56,9 @@ class ContainerNode:
error_message = ConfigurationErrorMessage.getInstance()
error_message.addFaultyContainers(container_id)
return None
self.container = container_list[0]
self._container = container_list[0]
return self.container
return self._container
def __str__(self) -> str:
return "%s[%s]" % (self.__class__.__name__, self.getMetaDataEntry("id"))

View file

@ -21,7 +21,6 @@ from .VariantType import VariantType
if TYPE_CHECKING:
from UM.Settings.DefinitionContainer import DefinitionContainer
from UM.Settings.InstanceContainer import InstanceContainer
from cura.Settings.GlobalStack import GlobalStack
from cura.Settings.ExtruderStack import ExtruderStack
@ -352,7 +351,8 @@ class MaterialManager(QObject):
if material_id not in material_id_metadata_dict:
material_id_metadata_dict[material_id] = node
Logger.log("d", "Exclude materials {excluded_materials} for machine {machine_definition_id}".format(excluded_materials = ", ".join(excluded_materials), machine_definition_id = machine_definition_id))
if excluded_materials:
Logger.log("d", "Exclude materials {excluded_materials} for machine {machine_definition_id}".format(excluded_materials = ", ".join(excluded_materials), machine_definition_id = machine_definition_id))
return material_id_metadata_dict

View file

@ -1,7 +1,7 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
from typing import Optional, List, Dict, Union
import os
import urllib.parse
from configparser import ConfigParser
@ -58,9 +58,9 @@ class SettingVisibilityPresetsModel(ListModel):
break
return result
def _populate(self):
def _populate(self) -> None:
from cura.CuraApplication import CuraApplication
items = []
items = [] # type: List[Dict[str, Union[str, int, List[str]]]]
for file_path in Resources.getAllResourcesOfType(CuraApplication.ResourceTypes.SettingVisibilityPreset):
try:
mime_type = MimeTypeDatabase.getMimeTypeForFile(file_path)
@ -79,7 +79,7 @@ class SettingVisibilityPresetsModel(ListModel):
if not parser.has_option("general", "name") or not parser.has_option("general", "weight"):
continue
settings = []
settings = [] # type: List[str]
for section in parser.sections():
if section == 'general':
continue
@ -98,7 +98,7 @@ class SettingVisibilityPresetsModel(ListModel):
except Exception:
Logger.logException("e", "Failed to load setting preset %s", file_path)
items.sort(key = lambda k: (int(k["weight"]), k["id"]))
items.sort(key = lambda k: (int(k["weight"]), k["id"])) # type: ignore
# Put "custom" at the top
items.insert(0, {"id": "custom",
"name": "Custom selection",
@ -147,7 +147,7 @@ class SettingVisibilityPresetsModel(ListModel):
def activePreset(self) -> str:
return self._active_preset_item["id"]
def _onPreferencesChanged(self, name: str):
def _onPreferencesChanged(self, name: str) -> None:
if name != "general/visible_settings":
return

View file

@ -24,9 +24,9 @@ class QualityChangesGroup(QualityGroup):
ConfigurationErrorMessage.getInstance().addFaultyContainers(node.getMetaDataEntry("id"))
return
if extruder_position is None: #Then we're a global quality changes profile.
if extruder_position is None: # Then we're a global quality changes profile.
self.node_for_global = node
else: #This is an extruder's quality changes profile.
else: # This is an extruder's quality changes profile.
self.nodes_for_extruders[extruder_position] = node
def __str__(self) -> str:

View file

@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Optional, cast, Dict, List
from PyQt5.QtCore import QObject, QTimer, pyqtSignal, pyqtSlot
from UM.Application import Application
from UM.ConfigurationErrorMessage import ConfigurationErrorMessage
from UM.Logger import Logger
from UM.Util import parseBool
@ -21,7 +20,6 @@ if TYPE_CHECKING:
from cura.Settings.GlobalStack import GlobalStack
from .QualityChangesGroup import QualityChangesGroup
from cura.CuraApplication import CuraApplication
from UM.Settings.ContainerRegistry import ContainerRegistry
#
@ -38,11 +36,11 @@ class QualityManager(QObject):
qualitiesUpdated = pyqtSignal()
def __init__(self, container_registry: "ContainerRegistry", parent = None) -> None:
def __init__(self, application: "CuraApplication", parent = None) -> None:
super().__init__(parent)
self._application = Application.getInstance() # type: CuraApplication
self._application = application
self._material_manager = self._application.getMaterialManager()
self._container_registry = container_registry
self._container_registry = self._application.getContainerRegistry()
self._empty_quality_container = self._application.empty_quality_container
self._empty_quality_changes_container = self._application.empty_quality_changes_container
@ -458,7 +456,7 @@ class QualityManager(QObject):
# stack and clear the user settings.
@pyqtSlot(str)
def createQualityChanges(self, base_name: str) -> None:
machine_manager = Application.getInstance().getMachineManager()
machine_manager = self._application.getMachineManager()
global_stack = machine_manager.activeMachine
if not global_stack:

View file

@ -1,7 +1,7 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, Dict, cast
from typing import Optional, Dict, cast, Any
from .ContainerNode import ContainerNode
from .QualityChangesGroup import QualityChangesGroup
@ -12,21 +12,21 @@ from .QualityChangesGroup import QualityChangesGroup
#
class QualityNode(ContainerNode):
def __init__(self, metadata: Optional[dict] = None) -> None:
def __init__(self, metadata: Optional[Dict[str, Any]] = None) -> None:
super().__init__(metadata = metadata)
self.quality_type_map = {} # type: Dict[str, QualityNode] # quality_type -> QualityNode for InstanceContainer
def getChildNode(self, child_key: str) -> Optional["QualityNode"]:
return self.children_map.get(child_key)
def addQualityMetadata(self, quality_type: str, metadata: dict):
def addQualityMetadata(self, quality_type: str, metadata: Dict[str, Any]):
if quality_type not in self.quality_type_map:
self.quality_type_map[quality_type] = QualityNode(metadata)
def getQualityNode(self, quality_type: str) -> Optional["QualityNode"]:
return self.quality_type_map.get(quality_type)
def addQualityChangesMetadata(self, quality_type: str, metadata: dict):
def addQualityChangesMetadata(self, quality_type: str, metadata: Dict[str, Any]):
if quality_type not in self.quality_type_map:
self.quality_type_map[quality_type] = QualityNode()
quality_type_node = self.quality_type_map[quality_type]

View file

@ -115,17 +115,24 @@ class VariantManager:
#
# Gets the default variant for the given machine definition.
# If the optional GlobalStack is given, the metadata information will be fetched from the GlobalStack instead of
# the DefinitionContainer. Because for machines such as UM2, you can enable Olsson Block, which will set
# "has_variants" to True in the GlobalStack. In those cases, we need to fetch metadata from the GlobalStack or
# it may not be correct.
#
def getDefaultVariantNode(self, machine_definition: "DefinitionContainer",
variant_type: VariantType) -> Optional["ContainerNode"]:
variant_type: "VariantType",
global_stack: Optional["GlobalStack"] = None) -> Optional["ContainerNode"]:
machine_definition_id = machine_definition.getId()
container_for_metadata_fetching = global_stack if global_stack is not None else machine_definition
preferred_variant_name = None
if variant_type == VariantType.BUILD_PLATE:
if parseBool(machine_definition.getMetaDataEntry("has_variant_buildplates", False)):
preferred_variant_name = machine_definition.getMetaDataEntry("preferred_variant_buildplate_name")
if parseBool(container_for_metadata_fetching.getMetaDataEntry("has_variant_buildplates", False)):
preferred_variant_name = container_for_metadata_fetching.getMetaDataEntry("preferred_variant_buildplate_name")
else:
if parseBool(machine_definition.getMetaDataEntry("has_variants", False)):
preferred_variant_name = machine_definition.getMetaDataEntry("preferred_variant_name")
if parseBool(container_for_metadata_fetching.getMetaDataEntry("has_variants", False)):
preferred_variant_name = container_for_metadata_fetching.getMetaDataEntry("preferred_variant_name")
node = None
if preferred_variant_name:

View file

@ -0,0 +1,112 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json
import random
from hashlib import sha512
from base64 import b64encode
from typing import Dict, Optional
import requests
from UM.Logger import Logger
from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settings
# Class containing several helpers to deal with the authorization flow.
class AuthorizationHelpers:
def __init__(self, settings: "OAuth2Settings") -> None:
self._settings = settings
self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL)
@property
# The OAuth2 settings object.
def settings(self) -> "OAuth2Settings":
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":
data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
"redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "",
"grant_type": "authorization_code",
"code": authorization_code,
"code_verifier": verification_code,
"scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "",
}
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore
# 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":
data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
"redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "",
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "",
}
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore
@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":
token_data = None
try:
token_data = json.loads(token_response.text)
except ValueError:
Logger.log("w", "Could not parse token response data: %s", token_response.text)
if not token_data:
return AuthenticationResponse(success=False, err_message="Could not read response.")
if token_response.status_code not in (200, 201):
return AuthenticationResponse(success=False, err_message=token_data["error_description"])
return AuthenticationResponse(success=True,
token_type=token_data["token_type"],
access_token=token_data["access_token"],
refresh_token=token_data["refresh_token"],
expires_in=token_data["expires_in"],
scope=token_data["scope"])
# 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"]:
token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = {
"Authorization": "Bearer {}".format(access_token)
})
if token_request.status_code not in (200, 201):
Logger.log("w", "Could not retrieve token data from auth server: %s", token_request.text)
return None
user_data = token_request.json().get("data")
if not user_data or not isinstance(user_data, dict):
Logger.log("w", "Could not parse user data from token: %s", user_data)
return None
return UserProfile(
user_id = user_data["user_id"],
username = user_data["username"],
profile_image_url = user_data.get("profile_image_url", "")
)
@staticmethod
# Generate a 16-character verification code.
# \param code_length: How long should the code be?
def generateVerificationCode(code_length: int = 16) -> str:
return "".join(random.choice("0123456789ABCDEF") for i in range(code_length))
@staticmethod
# Generates a base64 encoded sha512 encrypted version of a given string.
# \param verification_code:
# \return: The encrypted code in base64 format.
def generateVerificationCodeChallenge(verification_code: str) -> str:
encoded = sha512(verification_code.encode()).digest()
return b64encode(encoded, altchars = b"_-").decode()

View file

@ -0,0 +1,101 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, Callable, Tuple, Dict, Any, List, TYPE_CHECKING
from http.server import BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
from cura.OAuth2.Models import AuthenticationResponse, ResponseData, HTTP_STATUS
if TYPE_CHECKING:
from cura.OAuth2.Models import ResponseStatus
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers
# 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):
def __init__(self, request, client_address, server) -> None:
super().__init__(request, client_address, server)
# These values will be injected by the HTTPServer that this handler belongs to.
self.authorization_helpers = None # type: Optional["AuthorizationHelpers"]
self.authorization_callback = None # type: Optional[Callable[[AuthenticationResponse], None]]
self.verification_code = None # type: Optional[str]
def do_GET(self) -> None:
# Extract values from the query string.
parsed_url = urlparse(self.path)
query = parse_qs(parsed_url.query)
# Handle the possible requests
if parsed_url.path == "/callback":
server_response, token_response = self._handleCallback(query)
else:
server_response = self._handleNotFound()
token_response = None
# Send the data to the browser.
self._sendHeaders(server_response.status, server_response.content_type, server_response.redirect_uri)
if server_response.data_stream:
# If there is data in the response, we send it.
self._sendData(server_response.data_stream)
if token_response and self.authorization_callback is not None:
# Trigger the callback if we got a response.
# This will cause the server to shut down, so we do it at the very end of the request handling.
self.authorization_callback(token_response)
# Handler for the callback URL redirect.
# \param query: Dict containing the HTTP query parameters.
# \return: HTTP ResponseData containing a success page to show to the user.
def _handleCallback(self, query: Dict[Any, List]) -> Tuple[ResponseData, Optional[AuthenticationResponse]]:
code = self._queryGet(query, "code")
if code and self.authorization_helpers is not None and self.verification_code is not None:
# If the code was returned we get the access token.
token_response = self.authorization_helpers.getAccessTokenUsingAuthorizationCode(
code, self.verification_code)
elif self._queryGet(query, "error_code") == "user_denied":
# Otherwise we show an error message (probably the user clicked "Deny" in the auth dialog).
token_response = AuthenticationResponse(
success=False,
err_message="Please give the required permissions when authorizing this application."
)
else:
# We don't know what went wrong here, so instruct the user to check the logs.
token_response = AuthenticationResponse(
success=False,
error_message="Something unexpected happened when trying to log in, please try again."
)
if self.authorization_helpers is None:
return ResponseData(), token_response
return ResponseData(
status=HTTP_STATUS["REDIRECT"],
data_stream=b"Redirecting...",
redirect_uri=self.authorization_helpers.settings.AUTH_SUCCESS_REDIRECT if token_response.success else
self.authorization_helpers.settings.AUTH_FAILED_REDIRECT
), token_response
@staticmethod
# Handle all other non-existing server calls.
def _handleNotFound() -> ResponseData:
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:
self.send_response(status.code, status.message)
self.send_header("Content-type", content_type)
if redirect_uri:
self.send_header("Location", redirect_uri)
self.end_headers()
def _sendData(self, data: bytes) -> None:
self.wfile.write(data)
@staticmethod
# Convenience Helper for getting values from a pre-parsed query string
def _queryGet(query_data: Dict[Any, List], key: str, default: Optional[str]=None) -> Optional[str]:
return query_data.get(key, [default])[0]

View file

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

View file

@ -0,0 +1,168 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json
import webbrowser
from typing import Optional, TYPE_CHECKING
from urllib.parse import urlencode
from UM.Logger import Logger
from UM.Signal import Signal
from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers
from cura.OAuth2.Models import AuthenticationResponse
if TYPE_CHECKING:
from cura.OAuth2.Models import UserProfile, OAuth2Settings
from UM.Preferences import Preferences
class AuthorizationService:
"""
The authorization service is responsible for handling the login flow,
storing user credentials and providing account information.
"""
# Emit signal when authentication is completed.
onAuthStateChanged = Signal()
# Emit signal when authentication failed.
onAuthenticationError = Signal()
def __init__(self, settings: "OAuth2Settings", preferences: Optional["Preferences"] = None) -> None:
self._settings = settings
self._auth_helpers = AuthorizationHelpers(settings)
self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL)
self._auth_data = None # type: Optional[AuthenticationResponse]
self._user_profile = None # type: Optional["UserProfile"]
self._preferences = preferences
self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True)
def initialize(self, preferences: Optional["Preferences"] = None) -> None:
if preferences is not None:
self._preferences = preferences
if self._preferences:
self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}")
# Get the user profile as obtained from the JWT (JSON Web Token).
# If the JWT is not yet parsed, calling this will take care of that.
# \return UserProfile if a user is logged in, None otherwise.
# \sa _parseJWT
def getUserProfile(self) -> Optional["UserProfile"]:
if not self._user_profile:
# If no user profile was stored locally, we try to get it from JWT.
self._user_profile = self._parseJWT()
if not self._user_profile:
# If there is still no user profile from the JWT, we have to log in again.
return None
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"]:
if not self._auth_data or self._auth_data.access_token is None:
# If no auth data exists, we should always log in again.
return None
user_data = self._auth_helpers.parseJWT(self._auth_data.access_token)
if user_data:
# If the profile was found, we return it immediately.
return user_data
# The JWT was expired or invalid and we should request a new one.
if self._auth_data.refresh_token is None:
return None
self._auth_data = self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)
if not self._auth_data or self._auth_data.access_token is None:
# The token could not be refreshed using the refresh token. We should login again.
return None
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]:
if not self.getUserProfile():
# We check if we can get the user profile.
# If we can't get it, that means the access token (JWT) was invalid or expired.
return None
if self._auth_data is None:
return None
return self._auth_data.access_token
# Try to refresh the access token. This should be used when it has expired.
def refreshAccessToken(self) -> 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.")
return
self._storeAuthData(self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token))
self.onAuthStateChanged.emit(logged_in=True)
# Delete the authentication data that we have stored locally (eg; logout)
def deleteAuthData(self) -> None:
if self._auth_data is not None:
self._storeAuthData()
self.onAuthStateChanged.emit(logged_in=False)
# Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login.
def startAuthorizationFlow(self) -> None:
Logger.log("d", "Starting new OAuth2 flow...")
# Create the tokens needed for the code challenge (PKCE) extension for OAuth2.
# This is needed because the CuraDrivePlugin is a untrusted (open source) client.
# More details can be found at https://tools.ietf.org/html/rfc7636.
verification_code = self._auth_helpers.generateVerificationCode()
challenge_code = self._auth_helpers.generateVerificationCodeChallenge(verification_code)
# Create the query string needed for the OAuth2 flow.
query_string = urlencode({
"client_id": self._settings.CLIENT_ID,
"redirect_uri": self._settings.CALLBACK_URL,
"scope": self._settings.CLIENT_SCOPES,
"response_type": "code",
"state": "CuraDriveIsAwesome",
"code_challenge": challenge_code,
"code_challenge_method": "S512"
})
# Open the authorization page in a new browser window.
webbrowser.open_new("{}?{}".format(self._auth_url, query_string))
# Start a local web server to receive the callback URL on.
self._server.start(verification_code)
# Callback method for the authentication flow.
def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:
if auth_response.success:
self._storeAuthData(auth_response)
self.onAuthStateChanged.emit(logged_in=True)
else:
self.onAuthenticationError.emit(logged_in=False, error_message=auth_response.err_message)
self._server.stop() # Stop the web server at all times.
# Load authentication data from preferences.
def loadAuthDataFromPreferences(self) -> None:
if self._preferences is None:
Logger.log("e", "Unable to load authentication data, since no preference has been set!")
return
try:
preferences_data = json.loads(self._preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY))
if preferences_data:
self._auth_data = AuthenticationResponse(**preferences_data)
self.onAuthStateChanged.emit(logged_in=True)
except ValueError:
Logger.logException("w", "Could not load auth data from preferences")
# Store authentication data in preferences.
def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None:
if self._preferences is None:
Logger.log("e", "Unable to save authentication data, since no preference has been set!")
return
self._auth_data = auth_data
if auth_data:
self._user_profile = self.getUserProfile()
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data)))
else:
self._user_profile = None
self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)

View file

@ -0,0 +1,64 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import threading
from typing import Optional, Callable, Any, TYPE_CHECKING
from UM.Logger import Logger
from cura.OAuth2.AuthorizationRequestServer import AuthorizationRequestServer
from cura.OAuth2.AuthorizationRequestHandler import AuthorizationRequestHandler
if TYPE_CHECKING:
from cura.OAuth2.Models import AuthenticationResponse
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers
class LocalAuthorizationServer:
# The local LocalAuthorizationServer takes care of the oauth2 callbacks.
# Once the flow is completed, this server should be closed down again by calling stop()
# \param auth_helpers: An instance of the authorization helpers class.
# \param auth_state_changed_callback: A callback function to be called when the authorization state changes.
# \param daemon: Whether the server thread should be run in daemon mode. Note: Daemon threads are abruptly stopped
# at shutdown. Their resources (e.g. open files) may never be released.
def __init__(self, auth_helpers: "AuthorizationHelpers",
auth_state_changed_callback: Callable[["AuthenticationResponse"], Any],
daemon: bool) -> None:
self._web_server = None # type: Optional[AuthorizationRequestServer]
self._web_server_thread = None # type: Optional[threading.Thread]
self._web_server_port = auth_helpers.settings.CALLBACK_PORT
self._auth_helpers = auth_helpers
self._auth_state_changed_callback = auth_state_changed_callback
self._daemon = daemon
# Starts the local web server to handle the authorization callback.
# \param verification_code: The verification code part of the OAuth2 client identification.
def start(self, verification_code: str) -> None:
if self._web_server:
# If the server is already running (because of a previously aborted auth flow), we don't have to start it.
# We still inject the new verification code though.
self._web_server.setVerificationCode(verification_code)
return
if self._web_server_port is None:
raise Exception("Unable to start server without specifying the port.")
Logger.log("d", "Starting local web server to handle authorization callback on port %s", self._web_server_port)
# Create the server and inject the callback and code.
self._web_server = AuthorizationRequestServer(("0.0.0.0", self._web_server_port), AuthorizationRequestHandler)
self._web_server.setAuthorizationHelpers(self._auth_helpers)
self._web_server.setAuthorizationCallback(self._auth_state_changed_callback)
self._web_server.setVerificationCode(verification_code)
# 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.start()
# Stops the web server if it was running. It also does some cleanup.
def stop(self) -> None:
Logger.log("d", "Stopping local oauth2 web server...")
if self._web_server:
self._web_server.server_close()
self._web_server = None
self._web_server_thread = None

60
cura/OAuth2/Models.py Normal file
View file

@ -0,0 +1,60 @@
# Copyright (c) 2018 Ultimaker B.V.
from typing import Optional
class BaseModel:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
# OAuth OAuth2Settings data template.
class OAuth2Settings(BaseModel):
CALLBACK_PORT = None # type: Optional[int]
OAUTH_SERVER_URL = None # type: Optional[str]
CLIENT_ID = None # type: Optional[str]
CLIENT_SCOPES = None # type: Optional[str]
CALLBACK_URL = None # type: Optional[str]
AUTH_DATA_PREFERENCE_KEY = "" # type: str
AUTH_SUCCESS_REDIRECT = "https://ultimaker.com" # type: str
AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str
# User profile data template.
class UserProfile(BaseModel):
user_id = None # type: Optional[str]
username = None # type: Optional[str]
profile_image_url = None # type: Optional[str]
# Authentication data template.
class AuthenticationResponse(BaseModel):
"""Data comes from the token response with success flag and error message added."""
success = True # type: bool
token_type = None # type: Optional[str]
access_token = None # type: Optional[str]
refresh_token = None # type: Optional[str]
expires_in = None # type: Optional[str]
scope = None # type: Optional[str]
err_message = None # type: Optional[str]
# Response status template.
class ResponseStatus(BaseModel):
code = 200 # type: int
message = "" # type str
# Response data template.
class ResponseData(BaseModel):
status = None # type: ResponseStatus
data_stream = None # type: Optional[bytes]
redirect_uri = None # type: Optional[str]
content_type = "text/html" # type: str
# Possible HTTP responses.
HTTP_STATUS = {
"OK": ResponseStatus(code=200, message="OK"),
"NOT_FOUND": ResponseStatus(code=404, message="NOT FOUND"),
"REDIRECT": ResponseStatus(code=302, message="REDIRECT")
}

2
cura/OAuth2/__init__.py Normal file
View file

@ -0,0 +1,2 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.

View file

@ -9,6 +9,7 @@ from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Scene.SceneNode import SceneNode
from UM.Scene.Selection import Selection
from UM.i18n import i18nCatalog
from collections import defaultdict
catalog = i18nCatalog("cura")
@ -40,6 +41,8 @@ class ObjectsModel(ListModel):
filter_current_build_plate = Application.getInstance().getPreferences().getValue("view/filter_current_build_plate")
active_build_plate_number = self._build_plate_number
group_nr = 1
name_count_dict = defaultdict(int)
for node in DepthFirstIterator(Application.getInstance().getController().getScene().getRoot()):
if not isinstance(node, SceneNode):
continue
@ -55,6 +58,7 @@ class ObjectsModel(ListModel):
if not node.callDecoration("isGroup"):
name = node.getName()
else:
name = catalog.i18nc("@label", "Group #{group_nr}").format(group_nr = str(group_nr))
group_nr += 1
@ -63,6 +67,14 @@ class ObjectsModel(ListModel):
is_outside_build_area = node.isOutsideBuildArea()
else:
is_outside_build_area = False
#check if we already have an instance of the object based on name
name_count_dict[name] += 1
name_count = name_count_dict[name]
if name_count > 1:
name = "{0}({1})".format(name, name_count-1)
node.setName(name)
nodes.append({
"name": name,
@ -71,6 +83,7 @@ class ObjectsModel(ListModel):
"buildPlateNumber": node_build_plate_number,
"node": node
})
nodes = sorted(nodes, key=lambda n: n["name"])
self.setItems(nodes)

View file

@ -10,7 +10,6 @@ from typing import Dict
from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot
from UM.i18n import i18nCatalog
from UM.Logger import Logger
from UM.Qt.Duration import Duration
from UM.Scene.SceneNode import SceneNode
@ -52,6 +51,8 @@ class PrintInformation(QObject):
super().__init__(parent)
self._application = application
self.UNTITLED_JOB_NAME = "Untitled"
self.initializeCuraMessagePrintTimeProperties()
self._material_lengths = {} # indexed by build plate number
@ -70,12 +71,13 @@ class PrintInformation(QObject):
self._base_name = ""
self._abbr_machine = ""
self._job_name = ""
self._project_name = ""
self._active_build_plate = 0
self._initVariablesWithBuildPlate(self._active_build_plate)
self._multi_build_plate_model = self._application.getMultiBuildPlateModel()
ss = self._multi_build_plate_model.maxBuildPlate
self._application.globalContainerStackChanged.connect(self._updateJobName)
self._application.globalContainerStackChanged.connect(self.setToZeroPrintInformation)
self._application.fileLoaded.connect(self.setBaseName)
@ -300,13 +302,13 @@ class PrintInformation(QObject):
def _updateJobName(self):
if self._base_name == "":
self._job_name = "Untitled"
self._job_name = self.UNTITLED_JOB_NAME
self._is_user_specified_job_name = False
self.jobNameChanged.emit()
return
base_name = self._stripAccents(self._base_name)
self._setAbbreviatedMachineName()
self._defineAbbreviatedMachineName()
# Only update the job name when it's not user-specified.
if not self._is_user_specified_job_name:
@ -382,7 +384,7 @@ class PrintInformation(QObject):
## Created an acronym-like abbreviated machine name from the currently
# active machine name.
# Called each time the global stack is switched.
def _setAbbreviatedMachineName(self):
def _defineAbbreviatedMachineName(self):
global_container_stack = self._application.getGlobalContainerStack()
if not global_container_stack:
self._abbr_machine = ""

View file

@ -13,20 +13,20 @@ class ConfigurationModel(QObject):
configurationChanged = pyqtSignal()
def __init__(self):
def __init__(self) -> None:
super().__init__()
self._printer_type = None
self._printer_type = ""
self._extruder_configurations = [] # type: List[ExtruderConfigurationModel]
self._buildplate_configuration = None
self._buildplate_configuration = ""
def setPrinterType(self, printer_type):
self._printer_type = printer_type
@pyqtProperty(str, fset = setPrinterType, notify = configurationChanged)
def printerType(self):
def printerType(self) -> str:
return self._printer_type
def setExtruderConfigurations(self, extruder_configurations):
def setExtruderConfigurations(self, extruder_configurations: List["ExtruderConfigurationModel"]):
if self._extruder_configurations != extruder_configurations:
self._extruder_configurations = extruder_configurations
@ -39,16 +39,16 @@ class ConfigurationModel(QObject):
def extruderConfigurations(self):
return self._extruder_configurations
def setBuildplateConfiguration(self, buildplate_configuration):
def setBuildplateConfiguration(self, buildplate_configuration: str) -> None:
self._buildplate_configuration = buildplate_configuration
@pyqtProperty(str, fset = setBuildplateConfiguration, notify = configurationChanged)
def buildplateConfiguration(self):
def buildplateConfiguration(self) -> str:
return self._buildplate_configuration
## This method is intended to indicate whether the configuration is valid or not.
# The method checks if the mandatory fields are or not set
def isValid(self):
def isValid(self) -> bool:
if not self._extruder_configurations:
return False
for configuration in self._extruder_configurations:

View file

@ -66,7 +66,7 @@ class GenericOutputController(PrinterOutputController):
self._output_device.sendCommand("G28 Z")
def sendRawCommand(self, printer: "PrinterOutputModel", command: str):
self._output_device.sendCommand(command)
self._output_device.sendCommand(command.upper()) #Most printers only understand uppercase g-code commands.
def setJobState(self, job: "PrintJobOutputModel", state: str):
if state == "pause":

View file

@ -53,21 +53,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
self._sending_gcode = False
self._compressing_gcode = False
self._gcode = [] # type: List[str]
self._connection_state_before_timeout = None # type: Optional[ConnectionState]
printer_type = self._properties.get(b"machine", b"").decode("utf-8")
printer_type_identifiers = {
"9066": "ultimaker3",
"9511": "ultimaker3_extended",
"9051": "ultimaker_s5"
}
self._printer_type = "Unknown"
for key, value in printer_type_identifiers.items():
if printer_type.startswith(key):
self._printer_type = value
break
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None:
raise NotImplementedError("requestWrite needs to be implemented")
@ -341,7 +328,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
@pyqtProperty(str, constant = True)
def printerType(self) -> str:
return self._printer_type
return self._properties.get(b"printer_type", b"Unknown").decode("utf-8")
## IP adress of this printer
@pyqtProperty(str, constant = True)

View file

@ -5,6 +5,7 @@ from PyQt5.QtCore import QTimer
from UM.Application import Application
from UM.Math.Polygon import Polygon
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
from UM.Settings.ContainerRegistry import ContainerRegistry
@ -13,39 +14,49 @@ from cura.Scene import ConvexHullNode
import numpy
from typing import TYPE_CHECKING, Any, Optional
if TYPE_CHECKING:
from UM.Scene.SceneNode import SceneNode
from cura.Settings.GlobalStack import GlobalStack
from UM.Mesh.MeshData import MeshData
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):
def __init__(self):
def __init__(self) -> None:
super().__init__()
self._convex_hull_node = None
self._convex_hull_node = None # type: Optional["SceneNode"]
self._init2DConvexHullCache()
self._global_stack = None
self._global_stack = None # type: Optional[GlobalStack]
# Make sure the timer is created on the main thread
self._recompute_convex_hull_timer = None
Application.getInstance().callLater(self.createRecomputeConvexHullTimer)
self._recompute_convex_hull_timer = None # type: Optional[QTimer]
from cura.CuraApplication import CuraApplication
if CuraApplication.getInstance() is not None:
CuraApplication.getInstance().callLater(self.createRecomputeConvexHullTimer)
self._raft_thickness = 0.0
# For raft thickness, DRY
self._build_volume = Application.getInstance().getBuildVolume()
self._build_volume = CuraApplication.getInstance().getBuildVolume()
self._build_volume.raftThicknessChanged.connect(self._onChanged)
Application.getInstance().globalContainerStackChanged.connect(self._onGlobalStackChanged)
Application.getInstance().getController().toolOperationStarted.connect(self._onChanged)
Application.getInstance().getController().toolOperationStopped.connect(self._onChanged)
CuraApplication.getInstance().globalContainerStackChanged.connect(self._onGlobalStackChanged)
CuraApplication.getInstance().getController().toolOperationStarted.connect(self._onChanged)
CuraApplication.getInstance().getController().toolOperationStopped.connect(self._onChanged)
self._onGlobalStackChanged()
def createRecomputeConvexHullTimer(self):
def createRecomputeConvexHullTimer(self) -> None:
self._recompute_convex_hull_timer = QTimer()
self._recompute_convex_hull_timer.setInterval(200)
self._recompute_convex_hull_timer.setSingleShot(True)
self._recompute_convex_hull_timer.timeout.connect(self.recomputeConvexHull)
def setNode(self, node):
def setNode(self, node: "SceneNode") -> None:
previous_node = self._node
# Disconnect from previous node signals
if previous_node is not None and node is not previous_node:
@ -53,9 +64,9 @@ class ConvexHullDecorator(SceneNodeDecorator):
previous_node.parentChanged.disconnect(self._onChanged)
super().setNode(node)
self._node.transformationChanged.connect(self._onChanged)
self._node.parentChanged.connect(self._onChanged)
# Mypy doesn't understand that self._node is no longer optional, so just use the node.
node.transformationChanged.connect(self._onChanged)
node.parentChanged.connect(self._onChanged)
self._onChanged()
@ -63,37 +74,46 @@ class ConvexHullDecorator(SceneNodeDecorator):
def __deepcopy__(self, memo):
return ConvexHullDecorator()
## Get the unmodified 2D projected convex hull of the node
def getConvexHull(self):
## Get the unmodified 2D projected convex hull of the node (if any)
def getConvexHull(self) -> Optional[Polygon]:
if self._node is None:
return None
hull = self._compute2DConvexHull()
if self._global_stack and self._node:
if self._global_stack and self._node is not None and hull is not None:
# Parent can be None if node is just loaded.
if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and (self._node.getParent() is None or not self._node.getParent().callDecoration("isGroup")):
if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and not self.hasGroupAsParent(self._node):
hull = hull.getMinkowskiHull(Polygon(numpy.array(self._global_stack.getProperty("machine_head_polygon", "value"), numpy.float32)))
hull = self._add2DAdhesionMargin(hull)
return hull
## Get the convex hull of the node with the full head size
def getConvexHullHeadFull(self):
def getConvexHullHeadFull(self) -> Optional[Polygon]:
if self._node is None:
return None
return self._compute2DConvexHeadFull()
@staticmethod
def hasGroupAsParent(node: "SceneNode") -> bool:
parent = node.getParent()
if parent is None:
return False
return bool(parent.callDecoration("isGroup"))
## Get convex hull of the object + head size
# In case of printing all at once this is the same as the convex hull.
# For one at the time this is area with intersection of mirrored head
def getConvexHullHead(self):
def getConvexHullHead(self) -> Optional[Polygon]:
if self._node is None:
return None
if self._global_stack:
if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and (self._node.getParent() is None or not self._node.getParent().callDecoration("isGroup")):
if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and not self.hasGroupAsParent(self._node):
head_with_fans = self._compute2DConvexHeadMin()
if head_with_fans is None:
return None
head_with_fans_with_adhesion_margin = self._add2DAdhesionMargin(head_with_fans)
return head_with_fans_with_adhesion_margin
return None
@ -101,23 +121,24 @@ class ConvexHullDecorator(SceneNodeDecorator):
## Get convex hull of the node
# In case of printing all at once this is the same as the convex hull.
# For one at the time this is the area without the head.
def getConvexHullBoundary(self):
def getConvexHullBoundary(self) -> Optional[Polygon]:
if self._node is None:
return None
if self._global_stack:
if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and (self._node.getParent() is None or not self._node.getParent().callDecoration("isGroup")):
if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and not self.hasGroupAsParent(self._node):
# Printing one at a time and it's not an object in a group
return self._compute2DConvexHull()
return None
def recomputeConvexHullDelayed(self):
## The same as recomputeConvexHull, but using a timer if it was set.
def recomputeConvexHullDelayed(self) -> None:
if self._recompute_convex_hull_timer is not None:
self._recompute_convex_hull_timer.start()
else:
self.recomputeConvexHull()
def recomputeConvexHull(self):
def recomputeConvexHull(self) -> None:
controller = Application.getInstance().getController()
root = controller.getScene().getRoot()
if self._node is None or controller.isToolOperationActive() or not self.__isDescendant(root, self._node):
@ -132,27 +153,29 @@ class ConvexHullDecorator(SceneNodeDecorator):
hull_node = ConvexHullNode.ConvexHullNode(self._node, convex_hull, self._raft_thickness, root)
self._convex_hull_node = hull_node
def _onSettingValueChanged(self, key, property_name):
if property_name != "value": #Not the value that was changed.
def _onSettingValueChanged(self, key: str, property_name: str) -> None:
if property_name != "value": # Not the value that was changed.
return
if key in self._affected_settings:
self._onChanged()
if key in self._influencing_settings:
self._init2DConvexHullCache() #Invalidate the cache.
self._init2DConvexHullCache() # Invalidate the cache.
self._onChanged()
def _init2DConvexHullCache(self):
def _init2DConvexHullCache(self) -> None:
# Cache for the group code path in _compute2DConvexHull()
self._2d_convex_hull_group_child_polygon = None
self._2d_convex_hull_group_result = None
self._2d_convex_hull_group_child_polygon = None # type: Optional[Polygon]
self._2d_convex_hull_group_result = None # type: Optional[Polygon]
# Cache for the mesh code path in _compute2DConvexHull()
self._2d_convex_hull_mesh = None
self._2d_convex_hull_mesh_world_transform = None
self._2d_convex_hull_mesh_result = None
self._2d_convex_hull_mesh = None # type: Optional[MeshData]
self._2d_convex_hull_mesh_world_transform = None # type: Optional[Matrix]
self._2d_convex_hull_mesh_result = None # type: Optional[Polygon]
def _compute2DConvexHull(self):
def _compute2DConvexHull(self) -> Optional[Polygon]:
if self._node is None:
return None
if self._node.callDecoration("isGroup"):
points = numpy.zeros((0, 2), dtype=numpy.int32)
for child in self._node.getChildren():
@ -178,49 +201,47 @@ class ConvexHullDecorator(SceneNodeDecorator):
return offset_hull
else:
offset_hull = None
mesh = None
world_transform = None
if self._node.getMeshData():
mesh = self._node.getMeshData()
world_transform = self._node.getWorldTransformation()
# Check the cache
if mesh is self._2d_convex_hull_mesh and world_transform == self._2d_convex_hull_mesh_world_transform:
return self._2d_convex_hull_mesh_result
vertex_data = mesh.getConvexHullTransformedVertices(world_transform)
# Don't use data below 0.
# TODO; We need a better check for this as this gives poor results for meshes with long edges.
# Do not throw away vertices: the convex hull may be too small and objects can collide.
# vertex_data = vertex_data[vertex_data[:,1] >= -0.01]
if len(vertex_data) >= 4:
# Round the vertex data to 1/10th of a mm, then remove all duplicate vertices
# This is done to greatly speed up further convex hull calculations as the convex hull
# becomes much less complex when dealing with highly detailed models.
vertex_data = numpy.round(vertex_data, 1)
vertex_data = vertex_data[:, [0, 2]] # Drop the Y components to project to 2D.
# Grab the set of unique points.
#
# This basically finds the unique rows in the array by treating them as opaque groups of bytes
# which are as long as the 2 float64s in each row, and giving this view to numpy.unique() to munch.
# See http://stackoverflow.com/questions/16970982/find-unique-rows-in-numpy-array
vertex_byte_view = numpy.ascontiguousarray(vertex_data).view(
numpy.dtype((numpy.void, vertex_data.dtype.itemsize * vertex_data.shape[1])))
_, idx = numpy.unique(vertex_byte_view, return_index=True)
vertex_data = vertex_data[idx] # Select the unique rows by index.
hull = Polygon(vertex_data)
if len(vertex_data) >= 3:
convex_hull = hull.getConvexHull()
offset_hull = self._offsetHull(convex_hull)
else:
offset_hull = Polygon([])
mesh = self._node.getMeshData()
if mesh is None:
return Polygon([]) # Node has no mesh data, so just return an empty Polygon.
world_transform = self._node.getWorldTransformation()
# Check the cache
if mesh is self._2d_convex_hull_mesh and world_transform == self._2d_convex_hull_mesh_world_transform:
return self._2d_convex_hull_mesh_result
vertex_data = mesh.getConvexHullTransformedVertices(world_transform)
# Don't use data below 0.
# TODO; We need a better check for this as this gives poor results for meshes with long edges.
# Do not throw away vertices: the convex hull may be too small and objects can collide.
# vertex_data = vertex_data[vertex_data[:,1] >= -0.01]
if len(vertex_data) >= 4: # type: ignore # mypy and numpy don't play along well just yet.
# Round the vertex data to 1/10th of a mm, then remove all duplicate vertices
# This is done to greatly speed up further convex hull calculations as the convex hull
# becomes much less complex when dealing with highly detailed models.
vertex_data = numpy.round(vertex_data, 1)
vertex_data = vertex_data[:, [0, 2]] # Drop the Y components to project to 2D.
# Grab the set of unique points.
#
# This basically finds the unique rows in the array by treating them as opaque groups of bytes
# which are as long as the 2 float64s in each row, and giving this view to numpy.unique() to munch.
# See http://stackoverflow.com/questions/16970982/find-unique-rows-in-numpy-array
vertex_byte_view = numpy.ascontiguousarray(vertex_data).view(
numpy.dtype((numpy.void, vertex_data.dtype.itemsize * vertex_data.shape[1])))
_, idx = numpy.unique(vertex_byte_view, return_index=True)
vertex_data = vertex_data[idx] # Select the unique rows by index.
hull = Polygon(vertex_data)
if len(vertex_data) >= 3:
convex_hull = hull.getConvexHull()
offset_hull = self._offsetHull(convex_hull)
# Store the result in the cache
self._2d_convex_hull_mesh = mesh
self._2d_convex_hull_mesh_world_transform = world_transform
@ -228,24 +249,33 @@ class ConvexHullDecorator(SceneNodeDecorator):
return offset_hull
def _getHeadAndFans(self):
return Polygon(numpy.array(self._global_stack.getHeadAndFansCoordinates(), numpy.float32))
def _getHeadAndFans(self) -> Polygon:
if self._global_stack:
return Polygon(numpy.array(self._global_stack.getHeadAndFansCoordinates(), numpy.float32))
return Polygon()
def _compute2DConvexHeadFull(self):
return self._compute2DConvexHull().getMinkowskiHull(self._getHeadAndFans())
def _compute2DConvexHeadFull(self) -> Optional[Polygon]:
convex_hull = self._compute2DConvexHull()
if convex_hull:
return convex_hull.getMinkowskiHull(self._getHeadAndFans())
return None
def _compute2DConvexHeadMin(self):
headAndFans = self._getHeadAndFans()
mirrored = headAndFans.mirror([0, 0], [0, 1]).mirror([0, 0], [1, 0]) # Mirror horizontally & vertically.
def _compute2DConvexHeadMin(self) -> Optional[Polygon]:
head_and_fans = self._getHeadAndFans()
mirrored = head_and_fans.mirror([0, 0], [0, 1]).mirror([0, 0], [1, 0]) # Mirror horizontally & vertically.
head_and_fans = self._getHeadAndFans().intersectionConvexHulls(mirrored)
# Min head hull is used for the push free
min_head_hull = self._compute2DConvexHull().getMinkowskiHull(head_and_fans)
return min_head_hull
convex_hull = self._compute2DConvexHeadFull()
if convex_hull:
return convex_hull.getMinkowskiHull(head_and_fans)
return None
## Compensate given 2D polygon with adhesion margin
# \return 2D polygon with added margin
def _add2DAdhesionMargin(self, poly):
def _add2DAdhesionMargin(self, poly: Polygon) -> Polygon:
if not self._global_stack:
return Polygon()
# Compensate for raft/skirt/brim
# Add extra margin depending on adhesion type
adhesion_type = self._global_stack.getProperty("adhesion_type", "value")
@ -263,7 +293,7 @@ class ConvexHullDecorator(SceneNodeDecorator):
else:
raise Exception("Unknown bed adhesion type. Did you forget to update the convex hull calculations for your new bed adhesion type?")
# adjust head_and_fans with extra margin
# Adjust head_and_fans with extra margin
if extra_margin > 0:
extra_margin_polygon = Polygon.approximatedCircle(extra_margin)
poly = poly.getMinkowskiHull(extra_margin_polygon)
@ -274,7 +304,7 @@ class ConvexHullDecorator(SceneNodeDecorator):
# \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):
def _offsetHull(self, convex_hull: Polygon) -> Polygon:
horizontal_expansion = max(
self._getSettingProperty("xy_offset", "value"),
self._getSettingProperty("xy_offset_layer_0", "value")
@ -295,12 +325,12 @@ class ConvexHullDecorator(SceneNodeDecorator):
else:
return convex_hull
def _onChanged(self, *args):
def _onChanged(self, *args) -> None:
self._raft_thickness = self._build_volume.getRaftThickness()
if not args or args[0] == self._node:
self.recomputeConvexHullDelayed()
def _onGlobalStackChanged(self):
def _onGlobalStackChanged(self) -> None:
if self._global_stack:
self._global_stack.propertyChanged.disconnect(self._onSettingValueChanged)
self._global_stack.containersChanged.disconnect(self._onChanged)
@ -321,7 +351,9 @@ class ConvexHullDecorator(SceneNodeDecorator):
self._onChanged()
## Private convenience function to get a setting from the correct extruder (as defined by limit_to_extruder property).
def _getSettingProperty(self, setting_key, prop = "value"):
def _getSettingProperty(self, setting_key: str, prop: str = "value") -> Any:
if self._global_stack is None or self._node is None:
return None
per_mesh_stack = self._node.callDecoration("getStack")
if per_mesh_stack:
return per_mesh_stack.getProperty(setting_key, prop)
@ -339,8 +371,8 @@ class ConvexHullDecorator(SceneNodeDecorator):
# Limit_to_extruder is set. The global stack handles this then
return self._global_stack.getProperty(setting_key, prop)
## Returns true if node is a descendant or the same as the root node.
def __isDescendant(self, root, node):
## Returns True if node is a descendant or the same as the root node.
def __isDescendant(self, root: "SceneNode", node: Optional["SceneNode"]) -> bool:
if node is None:
return False
if root is node:

View file

@ -1,13 +1,19 @@
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
from typing import List
class GCodeListDecorator(SceneNodeDecorator):
def __init__(self):
def __init__(self) -> None:
super().__init__()
self._gcode_list = []
self._gcode_list = [] # type: List[str]
def getGCodeList(self):
def getGCodeList(self) -> List[str]:
return self._gcode_list
def setGCodeList(self, list):
def setGCodeList(self, list: List[str]):
self._gcode_list = list
def __deepcopy__(self, memo) -> "GCodeListDecorator":
copied_decorator = GCodeListDecorator()
copied_decorator.setGCodeList(self.getGCodeList())
return copied_decorator

View file

@ -2,11 +2,11 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
class SliceableObjectDecorator(SceneNodeDecorator):
def __init__(self):
def __init__(self) -> None:
super().__init__()
def isSliceable(self):
def isSliceable(self) -> bool:
return True
def __deepcopy__(self, memo):
def __deepcopy__(self, memo) -> "SliceableObjectDecorator":
return type(self)()

View file

@ -1,18 +1,19 @@
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
## A decorator that stores the amount an object has been moved below the platform.
class ZOffsetDecorator(SceneNodeDecorator):
def __init__(self):
def __init__(self) -> None:
super().__init__()
self._z_offset = 0
self._z_offset = 0.
def setZOffset(self, offset):
def setZOffset(self, offset: float) -> None:
self._z_offset = offset
def getZOffset(self):
def getZOffset(self) -> float:
return self._z_offset
def __deepcopy__(self, memo):
def __deepcopy__(self, memo) -> "ZOffsetDecorator":
copied_decorator = ZOffsetDecorator()
copied_decorator.setZOffset(self.getZOffset())
return copied_decorator

View file

@ -28,10 +28,10 @@ if TYPE_CHECKING:
from cura.Machines.MaterialNode import MaterialNode
from cura.Machines.QualityChangesGroup import QualityChangesGroup
from UM.PluginRegistry import PluginRegistry
from UM.Settings.ContainerRegistry import ContainerRegistry
from cura.Settings.MachineManager import MachineManager
from cura.Machines.MaterialManager import MaterialManager
from cura.Machines.QualityManager import QualityManager
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
catalog = i18nCatalog("cura")
@ -52,7 +52,7 @@ class ContainerManager(QObject):
self._application = application # type: CuraApplication
self._plugin_registry = self._application.getPluginRegistry() # type: PluginRegistry
self._container_registry = self._application.getContainerRegistry() # type: ContainerRegistry
self._container_registry = self._application.getContainerRegistry() # type: CuraContainerRegistry
self._machine_manager = self._application.getMachineManager() # type: MachineManager
self._material_manager = self._application.getMaterialManager() # type: MaterialManager
self._quality_manager = self._application.getQualityManager() # type: QualityManager
@ -391,7 +391,8 @@ class ContainerManager(QObject):
continue
mime_type = self._container_registry.getMimeTypeForContainer(container_type)
if mime_type is None:
continue
entry = {
"type": serialize_type,
"mime": mime_type,

View file

@ -291,7 +291,7 @@ class CuraContainerStack(ContainerStack):
# Helper to make sure we emit a PyQt signal on container changes.
def _onContainersChanged(self, container: Any) -> None:
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
# and its properties rather than, for example, the extruder. Defaults to simply returning the definition property.

View file

@ -114,7 +114,8 @@ class CuraStackBuilder:
# get variant container for extruders
extruder_variant_container = application.empty_variant_container
extruder_variant_node = variant_manager.getDefaultVariantNode(global_stack.definition, VariantType.NOZZLE)
extruder_variant_node = variant_manager.getDefaultVariantNode(global_stack.definition, VariantType.NOZZLE,
global_stack = global_stack)
extruder_variant_name = None
if extruder_variant_node:
extruder_variant_container = extruder_variant_node.getContainer()

View file

@ -348,8 +348,19 @@ class ExtruderManager(QObject):
# After 3.4, all single-extrusion machines have their own extruder definition files instead of reusing
# "fdmextruder". We need to check a machine here so its extruder definition is correct according to this.
def _fixSingleExtrusionMachineExtruderDefinition(self, global_stack: "GlobalStack") -> None:
container_registry = ContainerRegistry.getInstance()
expected_extruder_definition_0_id = global_stack.getMetaDataEntry("machine_extruder_trains")["0"]
extruder_stack_0 = global_stack.extruders.get("0")
# At this point, extruder stacks for this machine may not have been loaded yet. In this case, need to look in
# the container registry as well.
if not global_stack.extruders:
extruder_trains = container_registry.findContainerStacks(type = "extruder_train",
machine = global_stack.getId())
if extruder_trains:
for extruder in extruder_trains:
if extruder.getMetaDataEntry("position") == "0":
extruder_stack_0 = extruder
break
if extruder_stack_0 is None:
Logger.log("i", "No extruder stack for global stack [%s], create one", global_stack.getId())
@ -360,7 +371,6 @@ class ExtruderManager(QObject):
elif extruder_stack_0.definition.getId() != expected_extruder_definition_0_id:
Logger.log("e", "Single extruder printer [{printer}] expected extruder [{expected}], but got [{got}]. I'm making it [{expected}].".format(
printer = global_stack.getId(), expected = expected_extruder_definition_0_id, got = extruder_stack_0.definition.getId()))
container_registry = ContainerRegistry.getInstance()
extruder_definition = container_registry.findDefinitionContainers(id = expected_extruder_definition_0_id)[0]
extruder_stack_0.definition = extruder_definition

View file

@ -23,6 +23,7 @@ from .CuraContainerStack import CuraContainerStack
if TYPE_CHECKING:
from cura.Settings.ExtruderStack import ExtruderStack
## Represents the Global or Machine stack and its related containers.
#
class GlobalStack(CuraContainerStack):

View file

@ -1148,7 +1148,7 @@ class MachineManager(QObject):
self._fixQualityChangesGroupToNotSupported(quality_changes_group)
quality_changes_container = self._empty_quality_changes_container
quality_container = self._empty_quality_container
quality_container = self._empty_quality_container # type: Optional[InstanceContainer]
if quality_changes_group.node_for_global and quality_changes_group.node_for_global.getContainer():
quality_changes_container = cast(InstanceContainer, quality_changes_group.node_for_global.getContainer())
if quality_group is not None and quality_group.node_for_global and quality_group.node_for_global.getContainer():