1# Copyright (c) 2020 Ultimaker B.V.
2# Cura is released under the terms of the LGPLv3 or higher.
3
4import time
5import re
6import unicodedata
7from typing import Any, List, Dict, TYPE_CHECKING, Optional, cast, Set
8
9from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, QTimer
10
11from UM.ConfigurationErrorMessage import ConfigurationErrorMessage
12from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
13from UM.Settings.InstanceContainer import InstanceContainer
14from UM.Settings.Interfaces import ContainerInterface
15from UM.Signal import Signal
16from UM.FlameProfiler import pyqtSlot
17from UM import Util
18from UM.Logger import Logger
19from UM.Message import Message
20
21from UM.Settings.SettingFunction import SettingFunction
22from UM.Signal import postponeSignals, CompressTechnique
23
24import cura.CuraApplication  # Imported like this to prevent circular references.
25from UM.Util import parseBool
26
27from cura.Machines.ContainerNode import ContainerNode
28from cura.Machines.ContainerTree import ContainerTree
29from cura.Machines.Models.IntentCategoryModel import IntentCategoryModel
30
31from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice, ConnectionType
32from cura.PrinterOutput.Models.PrinterConfigurationModel import PrinterConfigurationModel
33from cura.PrinterOutput.Models.ExtruderConfigurationModel import ExtruderConfigurationModel
34from cura.PrinterOutput.Models.MaterialOutputModel import MaterialOutputModel
35from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
36from cura.Settings.ExtruderManager import ExtruderManager
37from cura.Settings.ExtruderStack import ExtruderStack
38from cura.Settings.cura_empty_instance_containers import (empty_definition_changes_container, empty_variant_container,
39                                                          empty_material_container, empty_quality_container,
40                                                          empty_quality_changes_container, empty_intent_container)
41from cura.UltimakerCloud.UltimakerCloudConstants import META_UM_LINKED_TO_ACCOUNT
42
43from .CuraStackBuilder import CuraStackBuilder
44
45from UM.i18n import i18nCatalog
46catalog = i18nCatalog("cura")
47from cura.Settings.GlobalStack import GlobalStack
48if TYPE_CHECKING:
49    from cura.CuraApplication import CuraApplication
50    from cura.Machines.MaterialNode import MaterialNode
51    from cura.Machines.QualityChangesGroup import QualityChangesGroup
52    from cura.Machines.QualityGroup import QualityGroup
53    from cura.Machines.VariantNode import VariantNode
54
55
56class MachineManager(QObject):
57    def __init__(self, application: "CuraApplication", parent: Optional["QObject"] = None) -> None:
58        super().__init__(parent)
59
60        self._active_container_stack = None     # type: Optional[ExtruderStack]
61        self._global_container_stack = None     # type: Optional[GlobalStack]
62
63        self._current_root_material_id = {}  # type: Dict[str, str]
64
65        self._default_extruder_position = "0"  # to be updated when extruders are switched on and off
66        self._num_user_settings = 0
67
68        self._instance_container_timer = QTimer()  # type: QTimer
69        self._instance_container_timer.setInterval(250)
70        self._instance_container_timer.setSingleShot(True)
71        self._instance_container_timer.timeout.connect(self.__emitChangedSignals)
72
73        self._application = application
74        self._container_registry = self._application.getContainerRegistry()
75        self._application.globalContainerStackChanged.connect(self._onGlobalContainerChanged)
76        self._container_registry.containerLoadComplete.connect(self._onContainersChanged)
77
78        #  When the global container is changed, active material probably needs to be updated.
79        self.globalContainerChanged.connect(self.activeMaterialChanged)
80        self.globalContainerChanged.connect(self.activeVariantChanged)
81        self.globalContainerChanged.connect(self.activeQualityChanged)
82
83        self.globalContainerChanged.connect(self.activeQualityChangesGroupChanged)
84        self.globalContainerChanged.connect(self.activeQualityGroupChanged)
85
86        self._stacks_have_errors = None  # type: Optional[bool]
87
88        extruder_manager = self._application.getExtruderManager()
89
90        extruder_manager.activeExtruderChanged.connect(self._onActiveExtruderStackChanged)
91        extruder_manager.activeExtruderChanged.connect(self.activeMaterialChanged)
92        extruder_manager.activeExtruderChanged.connect(self.activeVariantChanged)
93        extruder_manager.activeExtruderChanged.connect(self.activeQualityChanged)
94
95        self.globalContainerChanged.connect(self.activeStackChanged)
96        ExtruderManager.getInstance().activeExtruderChanged.connect(self.activeStackChanged)
97        self.activeStackChanged.connect(self.activeStackValueChanged)
98
99        self._application.getPreferences().addPreference("cura/active_machine", "")
100
101        self._printer_output_devices = []  # type: List[PrinterOutputDevice]
102        self._application.getOutputDeviceManager().outputDevicesChanged.connect(self._onOutputDevicesChanged)
103        # There might already be some output devices by the time the signal is connected
104        self._onOutputDevicesChanged()
105
106        self._current_printer_configuration = PrinterConfigurationModel()   # Indicates the current configuration setup in this printer
107        self.activeMaterialChanged.connect(self._onCurrentConfigurationChanged)
108        self.activeVariantChanged.connect(self._onCurrentConfigurationChanged)
109        # Force to compute the current configuration
110        self._onCurrentConfigurationChanged()
111
112        self._application.callLater(self.setInitialActiveMachine)
113
114        containers = CuraContainerRegistry.getInstance().findInstanceContainers(id = self.activeMaterialId)  # type: List[InstanceContainer]
115        if containers:
116            containers[0].nameChanged.connect(self._onMaterialNameChanged)
117
118        self.rootMaterialChanged.connect(self._onRootMaterialChanged)
119
120        # Emit the printerConnectedStatusChanged when either globalContainerChanged or outputDevicesChanged are emitted
121        self.globalContainerChanged.connect(self.printerConnectedStatusChanged)
122        self.outputDevicesChanged.connect(self.printerConnectedStatusChanged)
123
124        # For updating active quality display name
125        self.activeQualityChanged.connect(self.activeQualityDisplayNameChanged)
126        self.activeIntentChanged.connect(self.activeQualityDisplayNameChanged)
127        self.activeQualityGroupChanged.connect(self.activeQualityDisplayNameChanged)
128        self.activeQualityChangesGroupChanged.connect(self.activeQualityDisplayNameChanged)
129
130        self.activeStackValueChanged.connect(self._reCalculateNumUserSettings)
131        self.numberExtrudersEnabledChanged.connect(self.correctPrintSequence)
132
133    activeQualityDisplayNameChanged = pyqtSignal()
134
135    activeQualityGroupChanged = pyqtSignal()
136    activeQualityChangesGroupChanged = pyqtSignal()
137
138    globalContainerChanged = pyqtSignal()  # Emitted whenever the global stack is changed (ie: when changing between printers, changing a global profile, but not when changing a value)
139    activeMaterialChanged = pyqtSignal()
140    activeVariantChanged = pyqtSignal()
141    activeQualityChanged = pyqtSignal()
142    activeIntentChanged = pyqtSignal()
143    activeStackChanged = pyqtSignal()  # Emitted whenever the active extruder stack is changed (ie: when switching the active extruder tab or changing between printers)
144    extruderChanged = pyqtSignal()  # Emitted whenever an extruder is activated or deactivated or the default extruder changes.
145
146    activeStackValueChanged = pyqtSignal()  # Emitted whenever a value inside the active stack is changed.
147    activeStackValidationChanged = pyqtSignal()  # Emitted whenever a validation inside active container is changed
148    stacksValidationChanged = pyqtSignal()  # Emitted whenever a validation is changed
149    numberExtrudersEnabledChanged = pyqtSignal()  # Emitted when the number of extruders that are enabled changed
150
151    blurSettings = pyqtSignal()  # Emitted to force fields in the advanced sidebar to un-focus, so they update properly
152
153    outputDevicesChanged = pyqtSignal()
154    currentConfigurationChanged = pyqtSignal()  # Emitted every time the current configurations of the machine changes
155    printerConnectedStatusChanged = pyqtSignal() # Emitted every time the active machine change or the outputdevices change
156
157    rootMaterialChanged = pyqtSignal()
158    numUserSettingsChanged = pyqtSignal()
159
160    def _reCalculateNumUserSettings(self):
161        if not self._global_container_stack:
162            if self._num_user_settings != 0:
163                self.numUserSettingsChanged.emit()
164                self._num_user_settings = 0
165            return
166        num_user_settings = self._global_container_stack.getTop().getNumInstances()
167        stacks = self._global_container_stack.extruderList
168        for stack in stacks:
169            num_user_settings += stack.getTop().getNumInstances()
170
171        if self._num_user_settings != num_user_settings:
172            self._num_user_settings = num_user_settings
173            self.numUserSettingsChanged.emit()
174
175    def setInitialActiveMachine(self) -> None:
176        active_machine_id = self._application.getPreferences().getValue("cura/active_machine")
177        if active_machine_id != "" and CuraContainerRegistry.getInstance().findContainerStacksMetadata(id = active_machine_id):
178            # An active machine was saved, so restore it.
179            self.setActiveMachine(active_machine_id)
180
181    def _onOutputDevicesChanged(self) -> None:
182        self._printer_output_devices = []
183        for printer_output_device in self._application.getOutputDeviceManager().getOutputDevices():
184            if isinstance(printer_output_device, PrinterOutputDevice):
185                self._printer_output_devices.append(printer_output_device)
186
187        self.outputDevicesChanged.emit()
188
189    @pyqtProperty(QObject, notify = currentConfigurationChanged)
190    def currentConfiguration(self) -> PrinterConfigurationModel:
191        return self._current_printer_configuration
192
193    def _onCurrentConfigurationChanged(self) -> None:
194        if not self._global_container_stack:
195            return
196
197        # Create the configuration model with the current data in Cura
198        self._current_printer_configuration.printerType = self._global_container_stack.definition.getName()
199
200        if len(self._current_printer_configuration.extruderConfigurations) != len(self._global_container_stack.extruderList):
201            self._current_printer_configuration.extruderConfigurations = [ExtruderConfigurationModel() for extruder in self._global_container_stack.extruderList]
202
203        for extruder, extruder_configuration in zip(self._global_container_stack.extruderList, self._current_printer_configuration.extruderConfigurations):
204            # For compare just the GUID is needed at this moment
205            mat_type = extruder.material.getMetaDataEntry("material") if extruder.material != empty_material_container else None
206            mat_guid = extruder.material.getMetaDataEntry("GUID") if extruder.material != empty_material_container else None
207            mat_color = extruder.material.getMetaDataEntry("color_name") if extruder.material != empty_material_container else None
208            mat_brand = extruder.material.getMetaDataEntry("brand") if extruder.material != empty_material_container else None
209            mat_name = extruder.material.getMetaDataEntry("name") if extruder.material != empty_material_container else None
210            material_model = MaterialOutputModel(mat_guid, mat_type, mat_color, mat_brand, mat_name)
211
212            extruder_configuration.position = int(extruder.getMetaDataEntry("position"))
213            extruder_configuration.material = material_model
214            extruder_configuration.hotendID = extruder.variant.getName() if extruder.variant != empty_variant_container else None
215
216        # An empty build plate configuration from the network printer is presented as an empty string, so use "" for an
217        # empty build plate.
218        self._current_printer_configuration.buildplateConfiguration = self._global_container_stack.getProperty("machine_buildplate_type", "value")\
219            if self._global_container_stack.variant != empty_variant_container else self._global_container_stack.getProperty("machine_buildplate_type", "default_value")
220        self.currentConfigurationChanged.emit()
221
222    @pyqtSlot(QObject, result = bool)
223    def matchesConfiguration(self, configuration: PrinterConfigurationModel) -> bool:
224        return self._current_printer_configuration == configuration
225
226    @pyqtProperty("QVariantList", notify = outputDevicesChanged)
227    def printerOutputDevices(self) -> List[PrinterOutputDevice]:
228        return self._printer_output_devices
229
230    @pyqtProperty(int, constant=True)
231    def totalNumberOfSettings(self) -> int:
232        return len(self.getAllSettingKeys())
233
234    def getAllSettingKeys(self) -> Set[str]:
235        general_definition_containers = CuraContainerRegistry.getInstance().findDefinitionContainers(id="fdmprinter")
236        if not general_definition_containers:
237            return set()
238        return general_definition_containers[0].getAllKeys()
239
240    def _onGlobalContainerChanged(self) -> None:
241        """Triggered when the global container stack is changed in CuraApplication."""
242
243        if self._global_container_stack:
244            try:
245                self._global_container_stack.containersChanged.disconnect(self._onContainersChanged)
246            except TypeError:
247                pass
248            try:
249                self._global_container_stack.propertyChanged.disconnect(self._onPropertyChanged)
250            except TypeError:
251                pass
252
253            for extruder_stack in self._global_container_stack.extruderList:
254                extruder_stack.propertyChanged.disconnect(self._onPropertyChanged)
255                extruder_stack.containersChanged.disconnect(self._onContainersChanged)
256
257        # Update the local global container stack reference
258        self._global_container_stack = self._application.getGlobalContainerStack()
259        if self._global_container_stack:
260            self.updateDefaultExtruder()
261            self.updateNumberExtrudersEnabled()
262        self.globalContainerChanged.emit()
263
264        # After switching the global stack we reconnect all the signals and set the variant and material references
265        if self._global_container_stack:
266            self._application.getPreferences().setValue("cura/active_machine", self._global_container_stack.getId())
267
268            self._global_container_stack.containersChanged.connect(self._onContainersChanged)
269            self._global_container_stack.propertyChanged.connect(self._onPropertyChanged)
270
271            # Global stack can have only a variant if it is a buildplate
272            global_variant = self._global_container_stack.variant
273            if global_variant != empty_variant_container:
274                if global_variant.getMetaDataEntry("hardware_type") != "buildplate":
275                    self._global_container_stack.setVariant(empty_variant_container)
276
277            # Set the global material to empty as we now use the extruder stack at all times - CURA-4482
278            global_material = self._global_container_stack.material
279            if global_material != empty_material_container:
280                self._global_container_stack.setMaterial(empty_material_container)
281
282            # Listen for changes on all extruder stacks
283            for extruder_stack in self._global_container_stack.extruderList:
284                extruder_stack.propertyChanged.connect(self._onPropertyChanged)
285                extruder_stack.containersChanged.connect(self._onContainersChanged)
286
287            self._onRootMaterialChanged()
288
289        self.activeQualityGroupChanged.emit()
290
291    def _onActiveExtruderStackChanged(self) -> None:
292        self.blurSettings.emit()  # Ensure no-one has focus.
293        self._active_container_stack = ExtruderManager.getInstance().getActiveExtruderStack()
294
295    def __emitChangedSignals(self) -> None:
296        self.activeQualityChanged.emit()
297        self.activeVariantChanged.emit()
298        self.activeMaterialChanged.emit()
299        self.activeIntentChanged.emit()
300
301        self.rootMaterialChanged.emit()
302        self.numberExtrudersEnabledChanged.emit()
303
304    def _onContainersChanged(self, container: ContainerInterface) -> None:
305        self._instance_container_timer.start()
306
307    def _onPropertyChanged(self, key: str, property_name: str) -> None:
308        if property_name == "value":
309            # Notify UI items, such as the "changed" star in profile pull down menu.
310            self.activeStackValueChanged.emit()
311
312    @pyqtSlot(str)
313    def setActiveMachine(self, stack_id: Optional[str]) -> None:
314        self.blurSettings.emit()  # Ensure no-one has focus.
315
316        if not stack_id:
317            self._application.setGlobalContainerStack(None)
318            self.globalContainerChanged.emit()
319            self._application.showAddPrintersUncancellableDialog.emit()
320            return
321
322        container_registry = CuraContainerRegistry.getInstance()
323        containers = container_registry.findContainerStacks(id = stack_id)
324        if not containers:
325            return
326
327        global_stack = cast(GlobalStack, containers[0])
328
329        # Make sure that the default machine actions for this machine have been added
330        self._application.getMachineActionManager().addDefaultMachineActions(global_stack)
331
332        extruder_manager = ExtruderManager.getInstance()
333        extruder_manager.fixSingleExtrusionMachineExtruderDefinition(global_stack)
334        if not global_stack.isValid():
335            # Mark global stack as invalid
336            ConfigurationErrorMessage.getInstance().addFaultyContainers(global_stack.getId())
337            return  # We're done here
338
339        self._global_container_stack = global_stack
340        extruder_manager.addMachineExtruders(global_stack)
341        self._application.setGlobalContainerStack(global_stack)
342
343        # Switch to the first enabled extruder
344        self.updateDefaultExtruder()
345        default_extruder_position = int(self.defaultExtruderPosition)
346        old_active_extruder_index = extruder_manager.activeExtruderIndex
347        extruder_manager.setActiveExtruderIndex(default_extruder_position)
348        if old_active_extruder_index == default_extruder_position:
349            # 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.
350            extruder_manager.activeExtruderChanged.emit()
351
352        self._validateVariantsAndMaterials(global_stack)
353
354    def _validateVariantsAndMaterials(self, global_stack) -> None:
355        # Validate if the machine has the correct variants and materials.
356        # It can happen that a variant or material is empty, even though the machine has them. This will ensure that
357        # that situation will be fixed (and not occur again, since it switches it out to the preferred variant or
358        # variant instead!)
359        machine_node = ContainerTree.getInstance().machines[global_stack.definition.getId()]
360        if not self._global_container_stack:
361            return
362        for extruder in self._global_container_stack.extruderList:
363            variant_name = extruder.variant.getName()
364            variant_node = machine_node.variants.get(variant_name)
365            if variant_node is None:
366                Logger.log("w", "An extruder has an unknown variant, switching it to the preferred variant")
367                self.setVariantByName(extruder.getMetaDataEntry("position"), machine_node.preferred_variant_name)
368                variant_node = machine_node.variants.get(machine_node.preferred_variant_name)
369
370            material_node = variant_node.materials.get(extruder.material.getMetaDataEntry("base_file"))
371            if material_node is None:
372                Logger.log("w", "An extruder has an unknown material, switching it to the preferred material")
373                if not self.setMaterialById(extruder.getMetaDataEntry("position"), machine_node.preferred_material):
374                    Logger.log("w", "Failed to switch to %s keeping old material instead", machine_node.preferred_material)
375
376
377    @staticmethod
378    def getMachine(definition_id: str, metadata_filter: Optional[Dict[str, str]] = None) -> Optional["GlobalStack"]:
379        """Given a definition id, return the machine with this id.
380
381        Optional: add a list of keys and values to filter the list of machines with the given definition id
382        :param definition_id: :type{str} definition id that needs to look for
383        :param metadata_filter: :type{dict} list of metadata keys and values used for filtering
384        """
385
386        if metadata_filter is None:
387            metadata_filter = {}
388        machines = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine", **metadata_filter)
389        for machine in machines:
390            if machine.definition.getId() == definition_id:
391                return cast(GlobalStack, machine)
392        return None
393
394    @pyqtSlot(str, result=bool)
395    @pyqtSlot(str, str, result = bool)
396    def addMachine(self, definition_id: str, name: Optional[str] = None) -> bool:
397        Logger.log("i", "Trying to add a machine with the definition id [%s]", definition_id)
398        if name is None:
399            definitions = CuraContainerRegistry.getInstance().findDefinitionContainers(id = definition_id)
400            if definitions:
401                name = definitions[0].getName()
402            else:
403                name = definition_id
404
405        new_stack = CuraStackBuilder.createMachine(cast(str, name), definition_id)
406        if new_stack:
407            # Instead of setting the global container stack here, we set the active machine and so the signals are emitted
408            self.setActiveMachine(new_stack.getId())
409        else:
410            Logger.log("w", "Failed creating a new machine!")
411            return False
412        return True
413
414    def _checkStacksHaveErrors(self) -> bool:
415        time_start = time.time()
416        if self._global_container_stack is None: #No active machine.
417            return False
418
419        if self._global_container_stack.hasErrors():
420            Logger.log("d", "Checking global stack for errors took %0.2f s and we found an error" % (time.time() - time_start))
421            return True
422
423        # Not a very pretty solution, but the extruder manager doesn't really know how many extruders there are
424        machine_extruder_count = self._global_container_stack.getProperty("machine_extruder_count", "value")
425        extruder_stacks = self._global_container_stack.extruderList
426        count = 1  # We start with the global stack
427        for stack in extruder_stacks:
428            md = stack.getMetaData()
429            if "position" in md and int(md["position"]) >= machine_extruder_count:
430                continue
431            count += 1
432            if stack.hasErrors():
433                Logger.log("d", "Checking %s stacks for errors took %.2f s and we found an error in stack [%s]" % (count, time.time() - time_start, str(stack)))
434                return True
435
436        Logger.log("d", "Checking %s stacks for errors took %.2f s" % (count, time.time() - time_start))
437        return False
438
439    @pyqtProperty(bool, notify = numUserSettingsChanged)
440    def hasUserSettings(self) -> bool:
441        return self._num_user_settings != 0
442
443    @pyqtProperty(int, notify = numUserSettingsChanged)
444    def numUserSettings(self) -> int:
445        return self._num_user_settings
446
447    @pyqtSlot(str)
448    def clearUserSettingAllCurrentStacks(self, key: str) -> None:
449        """Delete a user setting from the global stack and all extruder stacks.
450
451        :param key: :type{str} the name of the key to delete
452        """
453        Logger.log("i", "Clearing the setting [%s] from all stacks", key)
454        if not self._global_container_stack:
455            return
456
457        send_emits_containers = []
458
459        top_container = self._global_container_stack.getTop()
460        top_container.removeInstance(key, postpone_emit=True)
461        send_emits_containers.append(top_container)
462
463        linked = not self._global_container_stack.getProperty(key, "settable_per_extruder") or \
464                      self._global_container_stack.getProperty(key, "limit_to_extruder") != "-1"
465
466        if not linked:
467            stack = ExtruderManager.getInstance().getActiveExtruderStack()
468            stacks = [stack]
469        else:
470            stacks = self._global_container_stack.extruderList
471
472        for stack in stacks:
473            if stack is not None:
474                container = stack.getTop()
475                container.removeInstance(key, postpone_emit=True)
476                send_emits_containers.append(container)
477
478        for container in send_emits_containers:
479            container.sendPostponedEmits()
480
481    @pyqtProperty(bool, notify = stacksValidationChanged)
482    def stacksHaveErrors(self) -> bool:
483        """Check if none of the stacks contain error states
484
485        Note that the _stacks_have_errors is cached due to performance issues
486        Calling _checkStack(s)ForErrors on every change is simply too expensive
487        """
488        return bool(self._stacks_have_errors)
489
490    @pyqtProperty(str, notify = globalContainerChanged)
491    def activeMachineFirmwareVersion(self) -> str:
492        if not self._printer_output_devices:
493            return ""
494        return self._printer_output_devices[0].firmwareVersion
495
496    @pyqtProperty(str, notify = globalContainerChanged)
497    def activeMachineAddress(self) -> str:
498        if not self._printer_output_devices:
499            return ""
500        return self._printer_output_devices[0].address
501
502    @pyqtProperty(bool, notify = printerConnectedStatusChanged)
503    def printerConnected(self) -> bool:
504        return bool(self._printer_output_devices)
505
506    @pyqtProperty(bool, notify = printerConnectedStatusChanged)
507    def activeMachineIsGroup(self) -> bool:
508        if self.activeMachine is None:
509            return False
510
511        group_size = int(self.activeMachine.getMetaDataEntry("group_size", "-1"))
512        return group_size > 1
513
514    @pyqtProperty(bool, notify = printerConnectedStatusChanged)
515    def activeMachineIsLinkedToCurrentAccount(self) -> bool:
516        return parseBool(self.activeMachine.getMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, "True"))
517
518    @pyqtProperty(bool, notify = printerConnectedStatusChanged)
519    def activeMachineHasNetworkConnection(self) -> bool:
520        # A network connection is only available if any output device is actually a network connected device.
521        return any(d.connectionType == ConnectionType.NetworkConnection for d in self._printer_output_devices)
522
523    @pyqtProperty(bool, notify = printerConnectedStatusChanged)
524    def activeMachineHasCloudConnection(self) -> bool:
525        # A cloud connection is only available if any output device actually is a cloud connected device.
526        return any(d.connectionType == ConnectionType.CloudConnection for d in self._printer_output_devices)
527
528    @pyqtProperty(bool, notify = printerConnectedStatusChanged)
529    def activeMachineHasCloudRegistration(self) -> bool:
530        return self.activeMachine is not None and ConnectionType.CloudConnection in self.activeMachine.configuredConnectionTypes
531
532    @pyqtProperty(bool, notify = printerConnectedStatusChanged)
533    def activeMachineIsUsingCloudConnection(self) -> bool:
534        return self.activeMachineHasCloudConnection and not self.activeMachineHasNetworkConnection
535
536    def activeMachineNetworkKey(self) -> str:
537        if self._global_container_stack:
538            return self._global_container_stack.getMetaDataEntry("um_network_key", "")
539        return ""
540
541    @pyqtProperty(str, notify = printerConnectedStatusChanged)
542    def activeMachineNetworkGroupName(self) -> str:
543        if self._global_container_stack:
544            return self._global_container_stack.getMetaDataEntry("group_name", "")
545        return ""
546
547    @pyqtProperty(QObject, notify = globalContainerChanged)
548    def activeMachine(self) -> Optional["GlobalStack"]:
549        return self._global_container_stack
550
551    @pyqtProperty(str, notify = activeStackChanged)
552    def activeStackId(self) -> str:
553        if self._active_container_stack:
554            return self._active_container_stack.getId()
555        return ""
556
557    @pyqtProperty(QObject, notify = activeStackChanged)
558    def activeStack(self) -> Optional["ExtruderStack"]:
559        return self._active_container_stack
560
561    @pyqtProperty(str, notify = activeMaterialChanged)
562    def activeMaterialId(self) -> str:
563        if self._active_container_stack:
564            material = self._active_container_stack.material
565            if material:
566                return material.getId()
567        return ""
568
569    @pyqtProperty(float, notify = activeQualityGroupChanged)
570    def activeQualityLayerHeight(self) -> float:
571        """Gets the layer height of the currently active quality profile.
572
573        This is indicated together with the name of the active quality profile.
574
575        :return: The layer height of the currently active quality profile. If
576        there is no quality profile, this returns the default layer height.
577        """
578
579        if not self._global_container_stack:
580            return 0
581        value = self._global_container_stack.getRawProperty("layer_height", "value", skip_until_container = self._global_container_stack.qualityChanges.getId())
582        if isinstance(value, SettingFunction):
583            value = value(self._global_container_stack)
584        return value
585
586    @pyqtProperty(str, notify = activeVariantChanged)
587    def globalVariantName(self) -> str:
588        if self._global_container_stack:
589            variant = self._global_container_stack.variant
590            if variant and not isinstance(variant, type(empty_variant_container)):
591                return variant.getName()
592        return ""
593
594    @pyqtProperty(str, notify = activeQualityGroupChanged)
595    def activeQualityType(self) -> str:
596        global_stack = self._application.getGlobalContainerStack()
597        if not global_stack:
598            return ""
599        return global_stack.quality.getMetaDataEntry("quality_type")
600
601    @pyqtProperty(bool, notify = activeQualityGroupChanged)
602    def isActiveQualitySupported(self) -> bool:
603        global_container_stack = self._application.getGlobalContainerStack()
604        if not global_container_stack:
605            return False
606        active_quality_group = self.activeQualityGroup()
607        if active_quality_group is None:
608            return False
609        return active_quality_group.is_available
610
611
612    @pyqtProperty(bool, notify = activeQualityGroupChanged)
613    def isActiveQualityExperimental(self) -> bool:
614        global_container_stack = self._application.getGlobalContainerStack()
615        if not global_container_stack:
616            return False
617        active_quality_group = self.activeQualityGroup()
618        if active_quality_group is None:
619            return False
620        return active_quality_group.is_experimental
621
622    @pyqtProperty(str, notify = activeIntentChanged)
623    def activeIntentCategory(self) -> str:
624        global_container_stack = self._application.getGlobalContainerStack()
625
626        if not global_container_stack:
627            return ""
628        return global_container_stack.getIntentCategory()
629
630    # Provies a list of extruder positions that have a different intent from the active one.
631    @pyqtProperty("QStringList", notify=activeIntentChanged)
632    def extruderPositionsWithNonActiveIntent(self):
633        global_container_stack = self._application.getGlobalContainerStack()
634
635        if not global_container_stack:
636            return []
637
638        active_intent_category = self.activeIntentCategory
639        result = []
640        for extruder in global_container_stack.extruderList:
641            if not extruder.isEnabled:
642                continue
643            category = extruder.intent.getMetaDataEntry("intent_category", "default")
644            if category != active_intent_category:
645                result.append(str(int(extruder.getMetaDataEntry("position")) + 1))
646
647        return result
648
649    @pyqtProperty(bool, notify = activeQualityChanged)
650    def isCurrentSetupSupported(self) -> bool:
651        """Returns whether there is anything unsupported in the current set-up.
652
653        The current set-up signifies the global stack and all extruder stacks,
654        so this indicates whether there is any container in any of the container
655        stacks that is not marked as supported.
656        """
657
658        if not self._global_container_stack:
659            return False
660        for stack in [self._global_container_stack] + self._global_container_stack.extruderList:
661            for container in stack.getContainers():
662                if not container:
663                    return False
664                if not Util.parseBool(container.getMetaDataEntry("supported", True)):
665                    return False
666        return True
667
668    @pyqtSlot(str)
669    def copyValueToExtruders(self, key: str) -> None:
670        """Copy the value of the setting of the current extruder to all other extruders as well as the global container."""
671
672        if self._active_container_stack is None or self._global_container_stack is None:
673            return
674        new_value = self._active_container_stack.getProperty(key, "value")
675
676        # Check in which stack the value has to be replaced
677        for extruder_stack in self._global_container_stack.extruderList:
678            if extruder_stack != self._active_container_stack and extruder_stack.getProperty(key, "value") != new_value:
679                extruder_stack.userChanges.setProperty(key, "value", new_value)  # TODO: nested property access, should be improved
680
681    @pyqtSlot()
682    def copyAllValuesToExtruders(self) -> None:
683        """Copy the value of all manually changed settings of the current extruder to all other extruders."""
684
685        if self._active_container_stack is None or self._global_container_stack is None:
686            return
687
688        for extruder_stack in self._global_container_stack.extruderList:
689            if extruder_stack != self._active_container_stack:
690                for key in self._active_container_stack.userChanges.getAllKeys():
691                    new_value = self._active_container_stack.getProperty(key, "value")
692
693                    # Check if the value has to be replaced
694                    extruder_stack.userChanges.setProperty(key, "value", new_value)
695
696    @pyqtProperty(str, notify = globalContainerChanged)
697    def activeQualityDefinitionId(self) -> str:
698        """Get the Definition ID to use to select quality profiles for the currently active machine
699
700        :returns: DefinitionID (string) if found, empty string otherwise
701        """
702        global_stack = self._application.getGlobalContainerStack()
703        if not global_stack:
704            return ""
705        return ContainerTree.getInstance().machines[global_stack.definition.getId()].quality_definition
706
707    @pyqtProperty(str, notify = globalContainerChanged)
708    def activeDefinitionVariantsName(self) -> str:
709        """Gets how the active definition calls variants
710
711        Caveat: per-definition-variant-title is currently not translated (though the fallback is)
712        """
713        fallback_title = catalog.i18nc("@label", "Nozzle")
714        if self._global_container_stack:
715            return self._global_container_stack.definition.getMetaDataEntry("variants_name", fallback_title)
716
717        return fallback_title
718
719    @pyqtSlot(str, str)
720    def renameMachine(self, machine_id: str, new_name: str) -> None:
721        container_registry = CuraContainerRegistry.getInstance()
722        machine_stack = container_registry.findContainerStacks(id = machine_id)
723        if machine_stack:
724            new_name = container_registry.createUniqueName("machine", machine_stack[0].getName(), new_name, machine_stack[0].definition.getName())
725            machine_stack[0].setName(new_name)
726            self.globalContainerChanged.emit()
727
728    @pyqtSlot(str)
729    def removeMachine(self, machine_id: str) -> None:
730        Logger.log("i", "Attempting to remove a machine with the id [%s]", machine_id)
731        # If the machine that is being removed is the currently active machine, set another machine as the active machine.
732        activate_new_machine = (self._global_container_stack and self._global_container_stack.getId() == machine_id)
733
734        # Activate a new machine before removing a machine because this is safer
735        if activate_new_machine:
736            machine_stacks = CuraContainerRegistry.getInstance().findContainerStacksMetadata(type = "machine")
737            other_machine_stacks = [s for s in machine_stacks if s["id"] != machine_id]
738            if other_machine_stacks:
739                self.setActiveMachine(other_machine_stacks[0]["id"])
740            else:
741                self.setActiveMachine(None)
742
743        metadatas = CuraContainerRegistry.getInstance().findContainerStacksMetadata(id = machine_id)
744        if not metadatas:
745            return  # machine_id doesn't exist. Nothing to remove.
746        metadata = metadatas[0]
747        ExtruderManager.getInstance().removeMachineExtruders(machine_id)
748        containers = CuraContainerRegistry.getInstance().findInstanceContainersMetadata(type = "user", machine = machine_id)
749        for container in containers:
750            CuraContainerRegistry.getInstance().removeContainer(container["id"])
751        machine_stacks = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine", name = machine_id)
752        if machine_stacks:
753            CuraContainerRegistry.getInstance().removeContainer(machine_stacks[0].definitionChanges.getId())
754        CuraContainerRegistry.getInstance().removeContainer(machine_id)
755
756        # If the printer that is being removed is a network printer, the hidden printers have to be also removed
757        group_id = metadata.get("group_id", None)
758        if group_id:
759            metadata_filter = {"group_id": group_id, "hidden": True}
760            hidden_containers = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine", **metadata_filter)
761            if hidden_containers:
762                # This reuses the method and remove all printers recursively
763                self.removeMachine(hidden_containers[0].getId())
764
765    @pyqtProperty(bool, notify = activeMaterialChanged)
766    def variantBuildplateCompatible(self) -> bool:
767        """The selected buildplate is compatible if it is compatible with all the materials in all the extruders"""
768
769        if not self._global_container_stack:
770            return True
771
772        buildplate_compatible = True  # It is compatible by default
773        for stack in self._global_container_stack.extruderList:
774            if not stack.isEnabled:
775                continue
776            material_container = stack.material
777            if material_container == empty_material_container:
778                continue
779            if material_container.getMetaDataEntry("buildplate_compatible"):
780                active_buildplate_name = self.activeMachine.variant.name
781                buildplate_compatible = buildplate_compatible and material_container.getMetaDataEntry("buildplate_compatible")[active_buildplate_name]
782
783        return buildplate_compatible
784
785    @pyqtProperty(bool, notify = activeMaterialChanged)
786    def variantBuildplateUsable(self) -> bool:
787        """The selected buildplate is usable if it is usable for all materials OR it is compatible for one but not compatible
788
789        for the other material but the buildplate is still usable
790        """
791        if not self._global_container_stack:
792            return True
793
794        # Here the next formula is being calculated:
795        # result = (not (material_left_compatible and material_right_compatible)) and
796        #           (material_left_compatible or material_left_usable) and
797        #           (material_right_compatible or material_right_usable)
798        result = not self.variantBuildplateCompatible
799
800        for stack in self._global_container_stack.extruderList:
801            material_container = stack.material
802            if material_container == empty_material_container:
803                continue
804            buildplate_compatible = material_container.getMetaDataEntry("buildplate_compatible")[self.activeVariantBuildplateName] if material_container.getMetaDataEntry("buildplate_compatible") else True
805            buildplate_usable = material_container.getMetaDataEntry("buildplate_recommended")[self.activeVariantBuildplateName] if material_container.getMetaDataEntry("buildplate_recommended") else True
806
807            result = result and (buildplate_compatible or buildplate_usable)
808
809        return result
810
811    @pyqtSlot(str, result = str)
812    def getDefinitionByMachineId(self, machine_id: str) -> Optional[str]:
813        """Get the Definition ID of a machine (specified by ID)
814
815        :param machine_id: string machine id to get the definition ID of
816        :returns: DefinitionID if found, None otherwise
817        """
818        containers = CuraContainerRegistry.getInstance().findContainerStacks(id = machine_id)
819        if containers:
820            return containers[0].definition.getId()
821        return None
822
823    def getIncompatibleSettingsOnEnabledExtruders(self, container: InstanceContainer) -> List[str]:
824        if self._global_container_stack is None:
825            return []
826        extruder_count = self._global_container_stack.getProperty("machine_extruder_count", "value")
827        result = []  # type: List[str]
828        for setting_instance in container.findInstances():
829            setting_key = setting_instance.definition.key
830            if not self._global_container_stack.getProperty(setting_key, "type") in ("extruder", "optional_extruder"):
831                continue
832
833            old_value = container.getProperty(setting_key, "value")
834            if isinstance(old_value, SettingFunction):
835                old_value = old_value(self._global_container_stack)
836            if int(old_value) < 0:
837                continue
838            if int(old_value) >= extruder_count or not self._global_container_stack.extruderList[int(old_value)].isEnabled:
839                result.append(setting_key)
840                Logger.log("d", "Reset setting [%s] in [%s] because its old value [%s] is no longer valid", setting_key, container, old_value)
841        return result
842
843    def correctExtruderSettings(self) -> None:
844        """Update extruder number to a valid value when the number of extruders are changed, or when an extruder is changed"""
845
846        if self._global_container_stack is None:
847            return
848        for setting_key in self.getIncompatibleSettingsOnEnabledExtruders(self._global_container_stack.userChanges):
849            self._global_container_stack.userChanges.removeInstance(setting_key)
850        add_user_changes = self.getIncompatibleSettingsOnEnabledExtruders(self._global_container_stack.qualityChanges)
851        for setting_key in add_user_changes:
852            # Apply quality changes that are incompatible to user changes, so we do not change the quality changes itself.
853            self._global_container_stack.userChanges.setProperty(setting_key, "value", self._default_extruder_position)
854        if add_user_changes:
855            caution_message = Message(
856                catalog.i18nc("@info:message Followed by a list of settings.", "Settings have been changed to match the current availability of extruders:") + " [{settings_list}]".format(settings_list = ", ".join(add_user_changes)),
857                lifetime = 0,
858                title = catalog.i18nc("@info:title", "Settings updated"))
859            caution_message.show()
860
861    def correctPrintSequence(self) -> None:
862        """
863        Sets the Print Sequence setting to "all-at-once" when there are more than one enabled extruders.
864
865        This setting has to be explicitly changed whenever we have more than one enabled extruders to make sure that the
866        Cura UI is properly updated to reset all the UI elements changes that occur due to the one-at-a-time mode (such
867        as the reduced build volume, the different convex hulls of the objects etc.).
868        """
869
870        setting_key = "print_sequence"
871        new_value = "all_at_once"
872
873        if self._global_container_stack is None \
874                or self._global_container_stack.getProperty(setting_key, "value") == new_value \
875                or self.numberExtrudersEnabled < 2:
876            return
877
878        user_changes_container = self._global_container_stack.userChanges
879        quality_changes_container = self._global_container_stack.qualityChanges
880        print_sequence_quality_changes = quality_changes_container.getProperty(setting_key, "value")
881        print_sequence_user_changes = user_changes_container.getProperty(setting_key, "value")
882
883        # If the user changes container has a value and its the incorrect value, then reset the setting in the user
884        # changes (so that the circular revert-changes arrow will now show up in the interface)
885        if print_sequence_user_changes and print_sequence_user_changes != new_value:
886            user_changes_container.removeInstance(setting_key)
887            Logger.log("d", "Resetting '{}' in container '{}' because there are more than 1 enabled extruders.".format(setting_key, user_changes_container))
888        # If the print sequence doesn't exist in either the user changes or the quality changes (yet it still has the
889        # wrong value in the global stack), or it exists in the quality changes and it has the wrong value, then set it
890        # in the user changes
891        elif (not print_sequence_quality_changes and not print_sequence_user_changes) \
892                or (print_sequence_quality_changes and print_sequence_quality_changes != new_value):
893            user_changes_container.setProperty(setting_key, "value", new_value)
894            Logger.log("d", "Setting '{}' in '{}' to '{}' because there are more than 1 enabled extruders.".format(setting_key, user_changes_container, new_value))
895
896    def setActiveMachineExtruderCount(self, extruder_count: int) -> None:
897        """Set the amount of extruders on the active machine (global stack)
898
899        :param extruder_count: int the number of extruders to set
900        """
901        if self._global_container_stack is None:
902            return
903        extruder_manager = self._application.getExtruderManager()
904
905        definition_changes_container = self._global_container_stack.definitionChanges
906        if not self._global_container_stack or definition_changes_container == empty_definition_changes_container:
907            return
908
909        previous_extruder_count = self._global_container_stack.getProperty("machine_extruder_count", "value")
910        if extruder_count == previous_extruder_count:
911            return
912
913        definition_changes_container.setProperty("machine_extruder_count", "value", extruder_count)
914
915        self.updateDefaultExtruder()
916        self.numberExtrudersEnabledChanged.emit()
917        self.correctExtruderSettings()
918
919        # Check to see if any objects are set to print with an extruder that will no longer exist
920        root_node = self._application.getController().getScene().getRoot()
921        for node in DepthFirstIterator(root_node):
922            if node.getMeshData():
923                extruder_nr = node.callDecoration("getActiveExtruderPosition")
924
925                if extruder_nr is not None and int(extruder_nr) > extruder_count - 1:
926                    extruder = extruder_manager.getExtruderStack(extruder_count - 1)
927                    if extruder is not None:
928                        node.callDecoration("setActiveExtruder", extruder.getId())
929                    else:
930                        Logger.log("w", "Could not find extruder to set active.")
931
932        # Make sure one of the extruder stacks is active
933        extruder_manager.setActiveExtruderIndex(0)
934
935        # Move settable_per_extruder values out of the global container
936        # After CURA-4482 this should not be the case anymore, but we still want to support older project files.
937        global_user_container = self._global_container_stack.userChanges
938
939        for setting_instance in global_user_container.findInstances():
940            setting_key = setting_instance.definition.key
941            settable_per_extruder = self._global_container_stack.getProperty(setting_key, "settable_per_extruder")
942
943            if settable_per_extruder:
944                limit_to_extruder = int(self._global_container_stack.getProperty(setting_key, "limit_to_extruder"))
945                extruder_position = max(0, limit_to_extruder)
946                extruder_stack = self._global_container_stack.extruderList[extruder_position]
947                if extruder_stack:
948                    extruder_stack.userChanges.setProperty(setting_key, "value", global_user_container.getProperty(setting_key, "value"))
949                else:
950                    Logger.log("e", "Unable to find extruder on position %s", extruder_position)
951                global_user_container.removeInstance(setting_key)
952
953        # Signal that the global stack has changed
954        self._application.globalContainerStackChanged.emit()
955        self.forceUpdateAllSettings()
956
957    def updateDefaultExtruder(self) -> None:
958        if self._global_container_stack is None:
959            return
960
961        old_position = self._default_extruder_position
962        new_default_position = "0"
963        for extruder in self._global_container_stack.extruderList:
964            if extruder.isEnabled:
965                new_default_position = extruder.getMetaDataEntry("position", "0")
966                break
967        if new_default_position != old_position:
968            self._default_extruder_position = new_default_position
969            self.extruderChanged.emit()
970
971    def updateNumberExtrudersEnabled(self) -> None:
972        if self._global_container_stack is None:
973            return
974        definition_changes_container = self._global_container_stack.definitionChanges
975        machine_extruder_count = self._global_container_stack.getProperty("machine_extruder_count", "value")
976        extruder_count = 0
977        for position, extruder in enumerate(self._global_container_stack.extruderList):
978            if extruder.isEnabled and int(position) < machine_extruder_count:
979                extruder_count += 1
980        if self.numberExtrudersEnabled != extruder_count:
981            definition_changes_container.setProperty("extruders_enabled_count", "value", extruder_count)
982            self.numberExtrudersEnabledChanged.emit()
983
984    @pyqtProperty(int, notify = numberExtrudersEnabledChanged)
985    def numberExtrudersEnabled(self) -> int:
986        if self._global_container_stack is None:
987            return 1
988        extruders_enabled_count = self._global_container_stack.definitionChanges.getProperty("extruders_enabled_count", "value")
989        if extruders_enabled_count is None:
990            extruders_enabled_count = len(self._global_container_stack.extruderList)
991        return extruders_enabled_count
992
993    @pyqtProperty(str, notify = extruderChanged)
994    def defaultExtruderPosition(self) -> str:
995        return self._default_extruder_position
996
997    @pyqtSlot()
998    def forceUpdateAllSettings(self) -> None:
999        """This will fire the propertiesChanged for all settings so they will be updated in the front-end"""
1000
1001        if self._global_container_stack is None:
1002            return
1003        property_names = ["value", "resolve", "validationState"]
1004        for container in [self._global_container_stack] + self._global_container_stack.extruderList:
1005            for setting_key in container.getAllKeys():
1006                container.propertiesChanged.emit(setting_key, property_names)
1007
1008    @pyqtSlot(int, bool)
1009    def setExtruderEnabled(self, position: int, enabled: bool) -> None:
1010        if self._global_container_stack is None or position >= len(self._global_container_stack.extruderList):
1011            Logger.log("w", "Could not find extruder on position %s.", position)
1012            return
1013        extruder = self._global_container_stack.extruderList[position]
1014
1015        extruder.setEnabled(enabled)
1016        self.updateDefaultExtruder()
1017        self.updateNumberExtrudersEnabled()
1018        self.correctExtruderSettings()
1019
1020        # Ensure that the quality profile is compatible with current combination, or choose a compatible one if available
1021        self._updateQualityWithMaterial()
1022        self.extruderChanged.emit()
1023        # Update material compatibility color
1024        self.activeQualityGroupChanged.emit()
1025        # Update items in SettingExtruder
1026        ExtruderManager.getInstance().extrudersChanged.emit(self._global_container_stack.getId())
1027
1028        # Also trigger the build plate compatibility to update
1029        self.activeMaterialChanged.emit()
1030        self.activeIntentChanged.emit()
1031
1032        # Force an update of resolve values
1033        property_names = ["resolve", "validationState"]
1034        for setting_key in self._global_container_stack.getAllKeys():
1035            self._global_container_stack.propertiesChanged.emit(setting_key, property_names)
1036
1037    def _onMaterialNameChanged(self) -> None:
1038        self.activeMaterialChanged.emit()
1039
1040    def _getContainerChangedSignals(self) -> List[Signal]:
1041        """Get the signals that signal that the containers changed for all stacks.
1042
1043        This includes the global stack and all extruder stacks. So if any
1044        container changed anywhere.
1045        """
1046
1047        if self._global_container_stack is None:
1048            return []
1049        return [s.containersChanged for s in self._global_container_stack.extruderList + [self._global_container_stack]]
1050
1051    @pyqtSlot(str, str, str)
1052    def setSettingForAllExtruders(self, setting_name: str, property_name: str, property_value: str) -> None:
1053        if self._global_container_stack is None:
1054            return
1055        for extruder in self._global_container_stack.extruderList:
1056            container = extruder.userChanges
1057            container.setProperty(setting_name, property_name, property_value)
1058
1059    @pyqtSlot(str)
1060    def resetSettingForAllExtruders(self, setting_name: str) -> None:
1061        """Reset all setting properties of a setting for all extruders.
1062
1063        :param setting_name: The ID of the setting to reset.
1064        """
1065        if self._global_container_stack is None:
1066            return
1067        for extruder in self._global_container_stack.extruderList:
1068            container = extruder.userChanges
1069            container.removeInstance(setting_name)
1070
1071    def _onRootMaterialChanged(self) -> None:
1072        """Update _current_root_material_id when the current root material was changed."""
1073
1074        self._current_root_material_id = {}
1075
1076        changed = False
1077
1078        if self._global_container_stack:
1079            for extruder in self._global_container_stack.extruderList:
1080                material_id = extruder.material.getMetaDataEntry("base_file")
1081                position = extruder.getMetaDataEntry("position")
1082                if position not in self._current_root_material_id or material_id != self._current_root_material_id[position]:
1083                    changed = True
1084                    self._current_root_material_id[position] = material_id
1085
1086        if changed:
1087            self.activeMaterialChanged.emit()
1088
1089    @pyqtProperty("QVariant", notify = rootMaterialChanged)
1090    def currentRootMaterialId(self) -> Dict[str, str]:
1091        return self._current_root_material_id
1092
1093    # Sets all quality and quality_changes containers to empty_quality and empty_quality_changes containers
1094    # for all stacks in the currently active machine.
1095    #
1096    def _setEmptyQuality(self) -> None:
1097        if self._global_container_stack is None:
1098            return
1099        self._global_container_stack.quality = empty_quality_container
1100        self._global_container_stack.qualityChanges = empty_quality_changes_container
1101        for extruder in self._global_container_stack.extruderList:
1102            extruder.quality = empty_quality_container
1103            extruder.qualityChanges = empty_quality_changes_container
1104
1105        self.activeQualityGroupChanged.emit()
1106        self.activeQualityChangesGroupChanged.emit()
1107        self._updateIntentWithQuality()
1108
1109    def _setQualityGroup(self, quality_group: Optional["QualityGroup"], empty_quality_changes: bool = True) -> None:
1110        if self._global_container_stack is None:
1111            return
1112        if quality_group is None:
1113            self._setEmptyQuality()
1114            return
1115
1116        if quality_group.node_for_global is None or quality_group.node_for_global.container is None:
1117            return
1118        for node in quality_group.nodes_for_extruders.values():
1119            if node.container is None:
1120                return
1121
1122        # Set quality and quality_changes for the GlobalStack
1123        self._global_container_stack.quality = quality_group.node_for_global.container
1124        if empty_quality_changes:
1125            self._global_container_stack.qualityChanges = empty_quality_changes_container
1126
1127        # Set quality and quality_changes for each ExtruderStack
1128        for position, node in quality_group.nodes_for_extruders.items():
1129            self._global_container_stack.extruderList[position].quality = node.container
1130            if empty_quality_changes:
1131                self._global_container_stack.extruderList[position].qualityChanges = empty_quality_changes_container
1132
1133        self.activeQualityGroupChanged.emit()
1134        self.activeQualityChangesGroupChanged.emit()
1135        self._updateIntentWithQuality()
1136
1137    def _fixQualityChangesGroupToNotSupported(self, quality_changes_group: "QualityChangesGroup") -> None:
1138        metadatas = [quality_changes_group.metadata_for_global] + list(quality_changes_group.metadata_per_extruder.values())
1139        for metadata in metadatas:
1140            metadata["quality_type"] = "not_supported"  # This actually changes the metadata of the container since they are stored by reference!
1141        quality_changes_group.quality_type = "not_supported"
1142        quality_changes_group.intent_category = "default"
1143
1144    def _setQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup") -> None:
1145        if self._global_container_stack is None:
1146            return  # Can't change that.
1147        quality_type = quality_changes_group.quality_type
1148        # A custom quality can be created based on "not supported".
1149        # In that case, do not set quality containers to empty.
1150        quality_group = None
1151        if quality_type != "not_supported":  # Find the quality group that the quality changes was based on.
1152            quality_group = ContainerTree.getInstance().getCurrentQualityGroups().get(quality_type)
1153            if quality_group is None:
1154                self._fixQualityChangesGroupToNotSupported(quality_changes_group)
1155
1156        container_registry = self._application.getContainerRegistry()
1157        quality_changes_container = empty_quality_changes_container
1158        quality_container = empty_quality_container  # type: InstanceContainer
1159        if quality_changes_group.metadata_for_global:
1160            global_containers = container_registry.findContainers(id = quality_changes_group.metadata_for_global["id"])
1161            if global_containers:
1162                quality_changes_container = global_containers[0]
1163        if quality_changes_group.metadata_for_global:
1164            containers = container_registry.findContainers(id = quality_changes_group.metadata_for_global["id"])
1165            if containers:
1166                quality_changes_container = cast(InstanceContainer, containers[0])
1167        if quality_group is not None and quality_group.node_for_global and quality_group.node_for_global.container:
1168            quality_container = quality_group.node_for_global.container
1169
1170        self._global_container_stack.quality = quality_container
1171        self._global_container_stack.qualityChanges = quality_changes_container
1172
1173        for position, extruder in enumerate(self._global_container_stack.extruderList):
1174            quality_node = None
1175            if quality_group is not None:
1176                quality_node = quality_group.nodes_for_extruders.get(position)
1177
1178            quality_changes_container = empty_quality_changes_container
1179            quality_container = empty_quality_container
1180            quality_changes_metadata = quality_changes_group.metadata_per_extruder.get(position)
1181            if quality_changes_metadata:
1182                containers = container_registry.findContainers(id = quality_changes_metadata["id"])
1183                if containers:
1184                    quality_changes_container = cast(InstanceContainer, containers[0])
1185            if quality_node and quality_node.container:
1186                quality_container = quality_node.container
1187
1188            extruder.quality = quality_container
1189            extruder.qualityChanges = quality_changes_container
1190
1191        self.setIntentByCategory(quality_changes_group.intent_category)
1192        self._reCalculateNumUserSettings()
1193
1194        self.activeQualityGroupChanged.emit()
1195        self.activeQualityChangesGroupChanged.emit()
1196
1197    def _setVariantNode(self, position: str, variant_node: "VariantNode") -> None:
1198        if self._global_container_stack is None:
1199            return
1200        self._global_container_stack.extruderList[int(position)].variant = variant_node.container
1201        self.activeVariantChanged.emit()
1202
1203    def _setGlobalVariant(self, container_node: "ContainerNode") -> None:
1204        if self._global_container_stack is None:
1205            return
1206        self._global_container_stack.variant = container_node.container
1207        if not self._global_container_stack.variant:
1208            self._global_container_stack.variant = self._application.empty_variant_container
1209
1210    def _setMaterial(self, position: str, material_node: Optional["MaterialNode"] = None) -> None:
1211        if self._global_container_stack is None:
1212            return
1213        if material_node and material_node.container:
1214            material_container = material_node.container
1215            self._global_container_stack.extruderList[int(position)].material = material_container
1216            root_material_id = material_container.getMetaDataEntry("base_file", None)
1217        else:
1218            self._global_container_stack.extruderList[int(position)].material = empty_material_container
1219            root_material_id = None
1220        # The _current_root_material_id is used in the MaterialMenu to see which material is selected
1221        if position not in self._current_root_material_id or root_material_id != self._current_root_material_id[position]:
1222            self._current_root_material_id[position] = root_material_id
1223            self.rootMaterialChanged.emit()
1224
1225    def activeMaterialsCompatible(self) -> bool:
1226        # Check material - variant compatibility
1227        if self._global_container_stack is not None:
1228            if Util.parseBool(self._global_container_stack.getMetaDataEntry("has_materials", False)):
1229                for extruder in self._global_container_stack.extruderList:
1230                    if not extruder.isEnabled:
1231                        continue
1232                    if not extruder.material.getMetaDataEntry("compatible"):
1233                        return False
1234        return True
1235
1236    def _updateQualityWithMaterial(self, *args: Any) -> None:
1237        """Update current quality type and machine after setting material"""
1238
1239        global_stack = self._application.getGlobalContainerStack()
1240        if global_stack is None:
1241            return
1242        Logger.log("d", "Updating quality/quality_changes due to material change")
1243        current_quality_type = global_stack.quality.getMetaDataEntry("quality_type")
1244        candidate_quality_groups = ContainerTree.getInstance().getCurrentQualityGroups()
1245        available_quality_types = {qt for qt, g in candidate_quality_groups.items() if g.is_available}
1246
1247        Logger.log("d", "Current quality type = [%s]", current_quality_type)
1248        if not self.activeMaterialsCompatible():
1249            if current_quality_type is not None:
1250                Logger.log("i", "Active materials are not compatible, setting all qualities to empty (Not Supported).")
1251                self._setEmptyQuality()
1252            return
1253
1254        if not available_quality_types:
1255            Logger.log("i", "No available quality types found, setting all qualities to empty (Not Supported).")
1256            self._setEmptyQuality()
1257            return
1258
1259        if current_quality_type in available_quality_types:
1260            Logger.log("i", "Current available quality type [%s] is available, applying changes.", current_quality_type)
1261            self._setQualityGroup(candidate_quality_groups[current_quality_type], empty_quality_changes = False)
1262            return
1263
1264        # The current quality type is not available so we use the preferred quality type if it's available,
1265        # otherwise use one of the available quality types.
1266        quality_type = sorted(list(available_quality_types))[0]
1267        if self._global_container_stack is None:
1268            Logger.log("e", "Global stack not present!")
1269            return
1270        preferred_quality_type = self._global_container_stack.getMetaDataEntry("preferred_quality_type")
1271        if preferred_quality_type in available_quality_types:
1272            quality_type = preferred_quality_type
1273
1274        Logger.log("i", "The current quality type [%s] is not available, switching to [%s] instead",
1275                   current_quality_type, quality_type)
1276        self._setQualityGroup(candidate_quality_groups[quality_type], empty_quality_changes = True)
1277
1278    def _updateIntentWithQuality(self):
1279        """Update the current intent after the quality changed"""
1280
1281        global_stack = self._application.getGlobalContainerStack()
1282        if global_stack is None:
1283            return
1284        Logger.log("d", "Updating intent due to quality change")
1285
1286        category = "default"
1287
1288        for extruder in global_stack.extruderList:
1289            if not extruder.isEnabled:
1290                continue
1291            current_category = extruder.intent.getMetaDataEntry("intent_category", "default")
1292            if current_category != "default" and current_category != category:
1293                category = current_category
1294                continue
1295            # It's also possible that the qualityChanges has an opinion about the intent_category.
1296            # This is in the case that a QC was made on an intent, but none of the materials have that intent.
1297            # If the user switches back, we do want the intent to be selected again.
1298            #
1299            # Do not ask empty quality changes for intent category.
1300            if extruder.qualityChanges.getId() == empty_quality_changes_container.getId():
1301                continue
1302            current_category = extruder.qualityChanges.getMetaDataEntry("intent_category", "default")
1303            if current_category != "default" and current_category != category:
1304                category = current_category
1305        self.setIntentByCategory(category)
1306
1307    @pyqtSlot()
1308    def updateMaterialWithVariant(self, position: Optional[str] = None) -> None:
1309        """Update the material profile in the current stacks when the variant is
1310
1311        changed.
1312        :param position: The extruder stack to update. If provided with None, all
1313        extruder stacks will be updated.
1314        """
1315        if self._global_container_stack is None:
1316            return
1317        if position is None:
1318            position_list = [str(position) for position in range(len(self._global_container_stack.extruderList))]
1319        else:
1320            position_list = [position]
1321
1322        for position_item in position_list:
1323            try:
1324                extruder = self._global_container_stack.extruderList[int(position_item)]
1325            except IndexError:
1326                continue
1327
1328            current_material_base_name = extruder.material.getMetaDataEntry("base_file")
1329            current_nozzle_name = extruder.variant.getMetaDataEntry("name")
1330
1331            # If we can keep the current material after the switch, try to do so.
1332            nozzle_node = ContainerTree.getInstance().machines[self._global_container_stack.definition.getId()].variants[current_nozzle_name]
1333            candidate_materials = nozzle_node.materials
1334            old_approximate_material_diameter = int(extruder.material.getMetaDataEntry("approximate_diameter", default = 3))
1335            new_approximate_material_diameter = int(self._global_container_stack.extruderList[int(position_item)].getApproximateMaterialDiameter())
1336
1337            # Only switch to the old candidate material if the approximate material diameter of the extruder stays the
1338            # same.
1339            if new_approximate_material_diameter == old_approximate_material_diameter and \
1340                    current_material_base_name in candidate_materials:  # The current material is also available after the switch. Retain it.
1341                new_material = candidate_materials[current_material_base_name]
1342                self._setMaterial(position_item, new_material)
1343            else:
1344                # The current material is not available, find the preferred one.
1345                approximate_material_diameter = int(self._global_container_stack.extruderList[int(position_item)].getApproximateMaterialDiameter())
1346                material_node = nozzle_node.preferredMaterial(approximate_material_diameter)
1347                self._setMaterial(position_item, material_node)
1348
1349    @pyqtSlot(str)
1350    def switchPrinterType(self, machine_name: str) -> None:
1351        """Given a printer definition name, select the right machine instance. In case it doesn't exist, create a new
1352
1353        instance with the same network key.
1354        """
1355        # Don't switch if the user tries to change to the same type of printer
1356        if self._global_container_stack is None or self._global_container_stack.definition.name == machine_name:
1357            return
1358        Logger.log("i", "Attempting to switch the printer type to [%s]", machine_name)
1359        # Get the definition id corresponding to this machine name
1360        definitions = CuraContainerRegistry.getInstance().findDefinitionContainers(name=machine_name)
1361        if not definitions:
1362            Logger.log("e", "Unable to switch printer type since it could not be found!")
1363            return
1364        machine_definition_id = definitions[0].getId()
1365        # Try to find a machine with the same network key
1366        metadata_filter = {"group_id": self._global_container_stack.getMetaDataEntry("group_id")}
1367        new_machine = self.getMachine(machine_definition_id, metadata_filter = metadata_filter)
1368        # If there is no machine, then create a new one and set it to the non-hidden instance
1369        if not new_machine:
1370            new_machine = CuraStackBuilder.createMachine(machine_definition_id + "_sync", machine_definition_id)
1371            if not new_machine:
1372                Logger.log("e", "Failed to create new machine when switching configuration.")
1373                return
1374
1375            for metadata_key in self._global_container_stack.getMetaData():
1376                if metadata_key in new_machine.getMetaData():
1377                    continue  # Don't copy the already preset stuff.
1378                new_machine.setMetaDataEntry(metadata_key, self._global_container_stack.getMetaDataEntry(metadata_key))
1379            # Special case, group_id should be overwritten!
1380            new_machine.setMetaDataEntry("group_id", self._global_container_stack.getMetaDataEntry("group_id"))
1381        else:
1382            Logger.log("i", "Found a %s with the key %s. Let's use it!", machine_name, self.activeMachineNetworkKey())
1383
1384        # Set the current printer instance to hidden (the metadata entry must exist)
1385        new_machine.setMetaDataEntry("hidden", False)
1386        self._global_container_stack.setMetaDataEntry("hidden", True)
1387
1388        # The new_machine does not contain user changes (global or per-extruder user changes).
1389        # Keep a temporary copy of the global and per-extruder user changes and transfer them to the user changes
1390        # of the new machine after the new_machine becomes active.
1391        global_user_changes = self._global_container_stack.userChanges
1392        per_extruder_user_changes = [extruder_stack.userChanges for extruder_stack in self._global_container_stack.extruderList]
1393
1394        self.setActiveMachine(new_machine.getId())
1395
1396        # Apply the global and per-extruder userChanges to the new_machine (which is of different type than the
1397        # previous one).
1398        self._global_container_stack.setUserChanges(global_user_changes)
1399        for i, user_changes in enumerate(per_extruder_user_changes):
1400            self._global_container_stack.extruderList[i].setUserChanges(per_extruder_user_changes[i])
1401
1402    @pyqtSlot(QObject)
1403    def applyRemoteConfiguration(self, configuration: PrinterConfigurationModel) -> None:
1404        if self._global_container_stack is None:
1405            return
1406        self.blurSettings.emit()
1407        container_registry = CuraContainerRegistry.getInstance()
1408        with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue):
1409            self.switchPrinterType(configuration.printerType)
1410
1411            extruders_to_disable = set()
1412
1413            # If an extruder that's currently used to print a model gets disabled due to the syncing, we need to show
1414            # a message explaining why.
1415            need_to_show_message = False
1416
1417            for extruder_configuration in configuration.extruderConfigurations:
1418                # We support "" or None, since the cloud uses None instead of empty strings
1419                extruder_has_hotend = extruder_configuration.hotendID not in ["", None]
1420                extruder_has_material = extruder_configuration.material.guid not in [None, "", "00000000-0000-0000-0000-000000000000"]
1421
1422                # If the machine doesn't have a hotend or material, disable this extruder
1423                if not extruder_has_hotend or not extruder_has_material:
1424                    extruders_to_disable.add(extruder_configuration.position)
1425
1426            # If there's no material and/or nozzle on the printer, enable the first extruder and disable the rest.
1427            if len(extruders_to_disable) == len(self._global_container_stack.extruderList):
1428                extruders_to_disable.remove(min(extruders_to_disable))
1429
1430            for extruder_configuration in configuration.extruderConfigurations:
1431                position = str(extruder_configuration.position)
1432                if int(position) >= len(self._global_container_stack.extruderList):
1433                    Logger.warning("Received a configuration for extruder {position}, which is out of bounds for this printer.".format(position=position))
1434                    continue  # Remote printer gave more extruders than what Cura had locally, e.g. because the user switched to a single-extruder printer while the sync was being processed.
1435
1436                # If the machine doesn't have a hotend or material, disable this extruder
1437                if int(position) in extruders_to_disable:
1438                    self._global_container_stack.extruderList[int(position)].setEnabled(False)
1439
1440                    need_to_show_message = True
1441
1442                else:
1443                    machine_node = ContainerTree.getInstance().machines.get(self._global_container_stack.definition.getId())
1444                    variant_node = machine_node.variants.get(extruder_configuration.hotendID)
1445                    if variant_node is None:
1446                        continue
1447                    self._setVariantNode(position, variant_node)
1448
1449                    # Find the material profile that the printer has stored.
1450                    # This might find one of the duplicates if the user duplicated the material to sync with. But that's okay; both have this GUID so both are correct.
1451                    approximate_diameter = int(self._global_container_stack.extruderList[int(position)].getApproximateMaterialDiameter())
1452                    materials_with_guid = container_registry.findInstanceContainersMetadata(GUID = extruder_configuration.material.guid, approximate_diameter = str(approximate_diameter), ignore_case = True)
1453                    material_container_node = variant_node.preferredMaterial(approximate_diameter)
1454                    if materials_with_guid:  # We also have the material profile that the printer wants to share.
1455                        base_file = materials_with_guid[0]["base_file"]
1456                        material_container_node = variant_node.materials.get(base_file, material_container_node)
1457
1458                    self._setMaterial(position, material_container_node)
1459                    self._global_container_stack.extruderList[int(position)].setEnabled(True)
1460                    self.updateMaterialWithVariant(position)
1461
1462            self.updateDefaultExtruder()
1463            self.updateNumberExtrudersEnabled()
1464            self._updateQualityWithMaterial()
1465
1466            if need_to_show_message:
1467                msg_str = "{extruders} is disabled because there is no material loaded. Please load a material or use custom configurations."
1468
1469                # Show human-readable extruder names such as "Extruder Left", "Extruder Front" instead of "Extruder 1, 2, 3".
1470                extruder_names = []
1471                for extruder_position in sorted(extruders_to_disable):
1472                    extruder_stack = self._global_container_stack.extruderList[int(extruder_position)]
1473                    extruder_name = extruder_stack.definition.getName()
1474                    extruder_names.append(extruder_name)
1475                extruders_str = ", ".join(extruder_names)
1476                msg_str = msg_str.format(extruders = extruders_str)
1477                message = Message(catalog.i18nc("@info:status", msg_str),
1478                                  title = catalog.i18nc("@info:title", "Extruder(s) Disabled"))
1479                message.show()
1480
1481        # See if we need to show the Discard or Keep changes screen
1482        if self.hasUserSettings and self._application.getPreferences().getValue("cura/active_mode") == 1:
1483            self._application.discardOrKeepProfileChanges()
1484
1485    @pyqtSlot("QVariant")
1486    def setGlobalVariant(self, container_node: "ContainerNode") -> None:
1487        self.blurSettings.emit()
1488        with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue):
1489            self._setGlobalVariant(container_node)
1490            self.updateMaterialWithVariant(None)  # Update all materials
1491            self._updateQualityWithMaterial()
1492
1493    @pyqtSlot(str, str, result = bool)
1494    def setMaterialById(self, position: str, root_material_id: str) -> bool:
1495        if self._global_container_stack is None:
1496            return False
1497
1498        machine_definition_id = self._global_container_stack.definition.id
1499        position = str(position)
1500        extruder_stack = self._global_container_stack.extruderList[int(position)]
1501        nozzle_name = extruder_stack.variant.getName()
1502
1503        materials = ContainerTree.getInstance().machines[machine_definition_id].variants[nozzle_name].materials
1504        if root_material_id in materials:
1505            self.setMaterial(position, materials[root_material_id])
1506            return True
1507        return False
1508
1509    @pyqtSlot(str, "QVariant")
1510    def setMaterial(self, position: str, container_node, global_stack: Optional["GlobalStack"] = None) -> None:
1511        """Global_stack: if you want to provide your own global_stack instead of the current active one
1512
1513        if you update an active machine, special measures have to be taken.
1514        """
1515        if global_stack is not None and global_stack != self._global_container_stack:
1516            global_stack.extruderList[int(position)].material = container_node.container
1517            return
1518        position = str(position)
1519        self.blurSettings.emit()
1520        with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue):
1521            self._setMaterial(position, container_node)
1522            self._updateQualityWithMaterial()
1523
1524        # See if we need to show the Discard or Keep changes screen
1525        if self.hasUserSettings and self._application.getPreferences().getValue("cura/active_mode") == 1:
1526            self._application.discardOrKeepProfileChanges()
1527
1528    @pyqtSlot(str, str)
1529    def setVariantByName(self, position: str, variant_name: str) -> None:
1530        if self._global_container_stack is None:
1531            return
1532        machine_definition_id = self._global_container_stack.definition.id
1533        machine_node = ContainerTree.getInstance().machines.get(machine_definition_id)
1534        variant_node = machine_node.variants.get(variant_name)
1535        if variant_node is None:
1536            Logger.error("There is no variant with the name {variant_name}.")
1537            return
1538        self.setVariant(position, variant_node)
1539
1540    @pyqtSlot(str, "QVariant")
1541    def setVariant(self, position: str, variant_node: "VariantNode") -> None:
1542        position = str(position)
1543        self.blurSettings.emit()
1544        with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue):
1545            self._setVariantNode(position, variant_node)
1546            self.updateMaterialWithVariant(position)
1547            self._updateQualityWithMaterial()
1548
1549        # See if we need to show the Discard or Keep changes screen
1550        if self.hasUserSettings and self._application.getPreferences().getValue("cura/active_mode") == 1:
1551            self._application.discardOrKeepProfileChanges()
1552
1553    @pyqtSlot(str)
1554    def setQualityGroupByQualityType(self, quality_type: str) -> None:
1555        if self._global_container_stack is None:
1556            return
1557        # Get all the quality groups for this global stack and filter out by quality_type
1558        self.setQualityGroup(ContainerTree.getInstance().getCurrentQualityGroups()[quality_type])
1559
1560    @pyqtSlot(QObject)
1561    def setQualityGroup(self, quality_group: "QualityGroup", no_dialog: bool = False, global_stack: Optional["GlobalStack"] = None) -> None:
1562        """Optionally provide global_stack if you want to use your own
1563
1564        The active global_stack is treated differently.
1565        """
1566        if global_stack is not None and global_stack != self._global_container_stack:
1567            if quality_group is None:
1568                Logger.log("e", "Could not set quality group because quality group is None")
1569                return
1570            if quality_group.node_for_global is None:
1571                Logger.log("e", "Could not set quality group [%s] because it has no node_for_global", str(quality_group))
1572                return
1573            # This is not changing the quality for the active machine !!!!!!!!
1574            global_stack.quality = quality_group.node_for_global.container
1575            for extruder_nr, extruder_stack in enumerate(global_stack.extruderList):
1576                quality_container = empty_quality_container
1577                if extruder_nr in quality_group.nodes_for_extruders:
1578                    container = quality_group.nodes_for_extruders[extruder_nr].container
1579                    quality_container = container if container is not None else quality_container
1580                extruder_stack.quality = quality_container
1581            return
1582
1583        self.blurSettings.emit()
1584        with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue):
1585            self._setQualityGroup(quality_group)
1586
1587        # See if we need to show the Discard or Keep changes screen
1588        if not no_dialog and self.hasUserSettings and self._application.getPreferences().getValue("cura/active_mode") == 1:
1589            self._application.discardOrKeepProfileChanges()
1590
1591    # The display name map of currently active quality.
1592    # The display name has 2 parts, a main part and a suffix part.
1593    # This display name is:
1594    #  - For built-in qualities (quality/intent): the quality type name, such as "Fine", "Normal", etc.
1595    #  - For custom qualities: <custom_quality_name> - <intent_name> - <quality_type_name>
1596    #        Examples:
1597    #          - "my_profile - Fine" (only based on a default quality, no intent involved)
1598    #          - "my_profile - Engineering - Fine" (based on an intent)
1599    @pyqtProperty("QVariantMap", notify = activeQualityDisplayNameChanged)
1600    def activeQualityDisplayNameMap(self) -> Dict[str, str]:
1601        global_stack = self._application.getGlobalContainerStack()
1602        if global_stack is None:
1603            return {"main": "",
1604                    "suffix": ""}
1605
1606        display_name = global_stack.quality.getName()
1607
1608        intent_category = self.activeIntentCategory
1609        if intent_category != "default":
1610            intent_display_name = IntentCategoryModel.translation(intent_category,
1611                                                                  "name",
1612                                                                  catalog.i18nc("@label", "Unknown"))
1613            display_name = "{intent_name} - {the_rest}".format(intent_name = intent_display_name,
1614                                                               the_rest = display_name)
1615
1616        main_part = display_name
1617        suffix_part = ""
1618
1619        # Not a custom quality
1620        if global_stack.qualityChanges != empty_quality_changes_container:
1621            main_part = self.activeQualityOrQualityChangesName
1622            suffix_part = display_name
1623
1624        return {"main": main_part,
1625                "suffix": suffix_part}
1626
1627    @pyqtSlot(str)
1628    def setIntentByCategory(self, intent_category: str) -> None:
1629        """Change the intent category of the current printer.
1630
1631        All extruders can change their profiles. If an intent profile is
1632        available with the desired intent category, that one will get chosen.
1633        Otherwise the intent profile will be left to the empty profile, which
1634        represents the "default" intent category.
1635        :param intent_category: The intent category to change to.
1636        """
1637
1638        global_stack = self._application.getGlobalContainerStack()
1639        if global_stack is None:
1640            return
1641        container_tree = ContainerTree.getInstance()
1642        for extruder in global_stack.extruderList:
1643            definition_id = global_stack.definition.getId()
1644            variant_name = extruder.variant.getName()
1645            material_base_file = extruder.material.getMetaDataEntry("base_file")
1646            quality_id = extruder.quality.getId()
1647            if quality_id == empty_quality_container.getId():
1648                extruder.intent = empty_intent_container
1649                continue
1650
1651            # Yes, we can find this in a single line of code. This makes it easier to read and it has the benefit
1652            # that it doesn't lump key errors together for the crashlogs
1653            try:
1654                machine_node = container_tree.machines[definition_id]
1655                variant_node = machine_node.variants[variant_name]
1656                material_node = variant_node.materials[material_base_file]
1657                quality_node = material_node.qualities[quality_id]
1658            except KeyError as e:
1659                Logger.error("Can't set the intent category '{category}' since the profile '{profile}' in the stack is not supported according to the container tree.".format(category = intent_category, profile = e))
1660                continue
1661
1662            for intent_node in quality_node.intents.values():
1663                if intent_node.intent_category == intent_category:  # Found an intent with the correct category.
1664                    extruder.intent = intent_node.container
1665                    break
1666            else:  # No intent had the correct category.
1667                extruder.intent = empty_intent_container
1668
1669    def activeQualityGroup(self) -> Optional["QualityGroup"]:
1670        """Get the currently activated quality group.
1671
1672        If no printer is added yet or the printer doesn't have quality profiles,
1673        this returns ``None``.
1674        :return: The currently active quality group.
1675        """
1676
1677        global_stack = self._application.getGlobalContainerStack()
1678        if not global_stack or global_stack.quality == empty_quality_container:
1679            return None
1680        return ContainerTree.getInstance().getCurrentQualityGroups().get(self.activeQualityType)
1681
1682    @pyqtProperty(str, notify = activeQualityGroupChanged)
1683    def activeQualityGroupName(self) -> str:
1684        """Get the name of the active quality group.
1685
1686        :return: The name of the active quality group.
1687        """
1688        quality_group = self.activeQualityGroup()
1689        if quality_group is None:
1690            return ""
1691        return quality_group.getName()
1692
1693    @pyqtSlot(QObject)
1694    def setQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup", no_dialog: bool = False) -> None:
1695        self.blurSettings.emit()
1696        with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue):
1697            self._setQualityChangesGroup(quality_changes_group)
1698
1699        # See if we need to show the Discard or Keep changes screen
1700        if not no_dialog and self.hasUserSettings and self._application.getPreferences().getValue("cura/active_mode") == 1:
1701            self._application.discardOrKeepProfileChanges()
1702
1703    @pyqtSlot()
1704    def resetToUseDefaultQuality(self) -> None:
1705        if self._global_container_stack is None:
1706            return
1707        with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue):
1708            self._setQualityGroup(self.activeQualityGroup())
1709            for stack in [self._global_container_stack] + self._global_container_stack.extruderList:
1710                stack.userChanges.clear()
1711
1712    @pyqtProperty(QObject, fset = setQualityChangesGroup, notify = activeQualityChangesGroupChanged)
1713    def activeQualityChangesGroup(self) -> Optional["QualityChangesGroup"]:
1714        global_stack = self._application.getGlobalContainerStack()
1715        if global_stack is None or global_stack.qualityChanges == empty_quality_changes_container:
1716            return None
1717
1718        all_group_list = ContainerTree.getInstance().getCurrentQualityChangesGroups()
1719        the_group = None
1720        for group in all_group_list:  # Match on the container ID of the global stack to find the quality changes group belonging to the active configuration.
1721            if group.metadata_for_global and group.metadata_for_global["id"] == global_stack.qualityChanges.getId():
1722                the_group = group
1723                break
1724
1725        return the_group
1726
1727    @pyqtProperty(bool, notify = activeQualityChangesGroupChanged)
1728    def hasCustomQuality(self) -> bool:
1729        global_stack = self._application.getGlobalContainerStack()
1730        return global_stack is None or global_stack.qualityChanges != empty_quality_changes_container
1731
1732    @pyqtProperty(str, notify = activeQualityGroupChanged)
1733    def activeQualityOrQualityChangesName(self) -> str:
1734        global_container_stack = self._application.getGlobalContainerStack()
1735        if not global_container_stack:
1736            return empty_quality_container.getName()
1737        if global_container_stack.qualityChanges != empty_quality_changes_container:
1738            return global_container_stack.qualityChanges.getName()
1739        return global_container_stack.quality.getName()
1740
1741    @pyqtProperty(bool, notify = activeQualityGroupChanged)
1742    def hasNotSupportedQuality(self) -> bool:
1743        global_container_stack = self._application.getGlobalContainerStack()
1744        return (not global_container_stack is None) and global_container_stack.quality == empty_quality_container and global_container_stack.qualityChanges == empty_quality_changes_container
1745
1746    @pyqtProperty(bool, notify = activeQualityGroupChanged)
1747    def isActiveQualityCustom(self) -> bool:
1748        global_stack = self._application.getGlobalContainerStack()
1749        if global_stack is None:
1750            return False
1751        return global_stack.qualityChanges != empty_quality_changes_container
1752
1753    def updateUponMaterialMetadataChange(self) -> None:
1754        if self._global_container_stack is None:
1755            return
1756        with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue):
1757            self.updateMaterialWithVariant(None)
1758            self._updateQualityWithMaterial()
1759
1760    @pyqtSlot(str, result = str)
1761    def getAbbreviatedMachineName(self, machine_type_name: str) -> str:
1762        """This function will translate any printer type name to an abbreviated printer type name"""
1763
1764        abbr_machine = ""
1765        for word in re.findall(r"[\w']+", machine_type_name):
1766            if word.lower() == "ultimaker":
1767                abbr_machine += "UM"
1768            elif word.isdigit():
1769                abbr_machine += word
1770            else:
1771                stripped_word = "".join(char for char in unicodedata.normalize("NFD", word.upper()) if unicodedata.category(char) != "Mn")
1772                # - use only the first character if the word is too long (> 3 characters)
1773                # - use the whole word if it's not too long (<= 3 characters)
1774                if len(stripped_word) > 3:
1775                    stripped_word = stripped_word[0]
1776                abbr_machine += stripped_word
1777
1778        return abbr_machine
1779