# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from typing import Any, cast, Dict, Optional, TYPE_CHECKING from PyQt6.QtCore import pyqtSlot, QObject, Qt, QTimer from UM.Logger import Logger from UM.Qt.ListModel import ListModel from UM.Settings.InstanceContainer import InstanceContainer # To create new profiles. import cura.CuraApplication # Imported this way to prevent circular imports. from cura.Settings.ContainerManager import ContainerManager from cura.Machines.ContainerTree import ContainerTree from cura.Settings.cura_empty_instance_containers import empty_quality_changes_container from cura.Settings.IntentManager import IntentManager from cura.Machines.Models.MachineModelUtils import fetchLayerHeight from cura.Machines.Models.IntentTranslations import intent_translations from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") if TYPE_CHECKING: from UM.Settings.Interfaces import ContainerInterface from cura.Machines.QualityChangesGroup import QualityChangesGroup from cura.Settings.ExtruderStack import ExtruderStack from cura.Settings.GlobalStack import GlobalStack class QualityManagementModel(ListModel): """This the QML model for the quality management page.""" NameRole = Qt.ItemDataRole.UserRole + 1 IsReadOnlyRole = Qt.ItemDataRole.UserRole + 2 QualityGroupRole = Qt.ItemDataRole.UserRole + 3 QualityTypeRole = Qt.ItemDataRole.UserRole + 4 QualityChangesGroupRole = Qt.ItemDataRole.UserRole + 5 IntentCategoryRole = Qt.ItemDataRole.UserRole + 6 SectionNameRole = Qt.ItemDataRole.UserRole + 7 def __init__(self, parent: Optional["QObject"] = None) -> None: super().__init__(parent) self.addRoleName(self.NameRole, "name") self.addRoleName(self.IsReadOnlyRole, "is_read_only") self.addRoleName(self.QualityGroupRole, "quality_group") self.addRoleName(self.QualityTypeRole, "quality_type") self.addRoleName(self.QualityChangesGroupRole, "quality_changes_group") self.addRoleName(self.IntentCategoryRole, "intent_category") self.addRoleName(self.SectionNameRole, "section_name") application = cura.CuraApplication.CuraApplication.getInstance() container_registry = application.getContainerRegistry() self._machine_manager = application.getMachineManager() self._machine_manager.activeQualityGroupChanged.connect(self._onChange) self._machine_manager.activeStackChanged.connect(self._onChange) self._machine_manager.extruderChanged.connect(self._onChange) self._machine_manager.globalContainerChanged.connect(self._onChange) self._extruder_manager = application.getExtruderManager() self._extruder_manager.extrudersChanged.connect(self._onChange) container_registry.containerAdded.connect(self._qualityChangesListChanged) container_registry.containerRemoved.connect(self._qualityChangesListChanged) container_registry.containerMetaDataChanged.connect(self._qualityChangesListChanged) self._update_timer = QTimer() self._update_timer.setInterval(100) self._update_timer.setSingleShot(True) self._update_timer.timeout.connect(self._update) self._onChange() def _onChange(self) -> None: self._update_timer.start() @pyqtSlot(QObject) def removeQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup") -> None: """Deletes a custom profile. It will be gone forever. :param quality_changes_group: The quality changes group representing the profile to delete. """ Logger.log("i", "Removing quality changes group {group_name}".format(group_name = quality_changes_group.name)) removed_quality_changes_ids = set() container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() for metadata in [quality_changes_group.metadata_for_global] + list(quality_changes_group.metadata_per_extruder.values()): container_id = metadata["id"] container_registry.removeContainer(container_id) removed_quality_changes_ids.add(container_id) # Reset all machines that have activated this custom profile. for global_stack in container_registry.findContainerStacks(type = "machine"): if global_stack.qualityChanges.getId() in removed_quality_changes_ids: global_stack.qualityChanges = empty_quality_changes_container for extruder_stack in container_registry.findContainerStacks(type = "extruder_train"): if extruder_stack.qualityChanges.getId() in removed_quality_changes_ids: extruder_stack.qualityChanges = empty_quality_changes_container @pyqtSlot(QObject, str, result = str) def renameQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup", new_name: str) -> str: """Rename a custom profile. Because the names must be unique, the new name may not actually become the name that was given. The actual name is returned by this function. :param quality_changes_group: The custom profile that must be renamed. :param new_name: The desired name for the profile. :return: The actual new name of the profile, after making the name unique. """ Logger.log("i", "Renaming QualityChangesGroup {old_name} to {new_name}.".format(old_name = quality_changes_group.name, new_name = new_name)) if new_name == quality_changes_group.name: Logger.log("i", "QualityChangesGroup name {name} unchanged.".format(name = quality_changes_group.name)) return new_name application = cura.CuraApplication.CuraApplication.getInstance() container_registry = application.getContainerRegistry() new_name = container_registry.uniqueName(new_name) # CURA-6842 # FIXME: setName() will trigger metaDataChanged signal that are connected with type Qt.AutoConnection. In this # case, setName() will trigger direct connections which in turn causes the quality changes group and the models # to update. Because multiple containers need to be renamed, and every time a container gets renamed, updates # gets triggered and this results in partial updates. For example, if we rename the global quality changes # container first, the rest of the system still thinks that I have selected "my_profile" instead of # "my_new_profile", but an update already gets triggered, and the quality changes group that's selected will # have no container for the global stack, because "my_profile" just got renamed to "my_new_profile". This results # in crashes because the rest of the system assumes that all data in a QualityChangesGroup will be correct. # # Renaming the container for the global stack in the end seems to be ok, because the assumption is mostly based # on the quality changes container for the global stack. for metadata in quality_changes_group.metadata_per_extruder.values(): extruder_container = cast(InstanceContainer, container_registry.findContainers(id = metadata["id"])[0]) extruder_container.setName(new_name) global_container = cast(InstanceContainer, container_registry.findContainers(id = quality_changes_group.metadata_for_global["id"])[0]) global_container.setName(new_name) quality_changes_group.name = new_name application.getMachineManager().activeQualityChanged.emit() application.getMachineManager().activeQualityGroupChanged.emit() return new_name @pyqtSlot(str, "QVariantMap") def duplicateQualityChanges(self, new_name: str, quality_model_item: Dict[str, Any]) -> None: """Duplicates a given quality profile OR quality changes profile. :param new_name: The desired name of the new profile. This will be made unique, so it might end up with a different name. :param quality_model_item: The item of this model to duplicate, as dictionary. See the descriptions of the roles of this list model. """ global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() if not global_stack: Logger.log("i", "No active global stack, cannot duplicate quality (changes) profile.") return container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() new_name = container_registry.uniqueName(new_name) intent_category = quality_model_item["intent_category"] quality_group = quality_model_item["quality_group"] quality_changes_group = quality_model_item["quality_changes_group"] if quality_changes_group is None: new_quality_changes = self._createQualityChanges(quality_group.quality_type, intent_category, new_name, global_stack, extruder_stack = None) container_registry.addContainer(new_quality_changes) for extruder in global_stack.extruderList: new_extruder_quality_changes = self._createQualityChanges(quality_group.quality_type, intent_category, new_name, global_stack, extruder_stack = extruder) container_registry.addContainer(new_extruder_quality_changes) else: for metadata in [quality_changes_group.metadata_for_global] + list(quality_changes_group.metadata_per_extruder.values()): containers = container_registry.findContainers(id = metadata["id"]) if not containers: continue container = containers[0] new_id = container_registry.uniqueName(container.getId()) container_registry.addContainer(container.duplicate(new_id, new_name)) @pyqtSlot(str) def createQualityChanges(self, base_name: str) -> None: """Create quality changes containers from the user containers in the active stacks. This will go through the global and extruder stacks and create quality_changes containers from the user containers in each stack. These then replace the quality_changes containers in the stack and clear the user settings. :param base_name: The new name for the quality changes profile. The final name of the profile might be different from this, because it needs to be made unique. """ machine_manager = cura.CuraApplication.CuraApplication.getInstance().getMachineManager() global_stack = machine_manager.activeMachine if not global_stack: return active_quality_name = machine_manager.activeQualityOrQualityChangesName if active_quality_name == "": Logger.log("w", "No quality container found in stack %s, cannot create profile", global_stack.getId()) return machine_manager.blurSettings.emit() if base_name is None or base_name == "": base_name = active_quality_name container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() unique_name = container_registry.uniqueName(base_name) # Go through the active stacks and create quality_changes containers from the user containers. container_manager = ContainerManager.getInstance() stack_list = [global_stack] + global_stack.extruderList for stack in stack_list: quality_container = stack.quality quality_changes_container = stack.qualityChanges if not quality_container or not quality_changes_container: Logger.log("w", "No quality or quality changes container found in stack %s, ignoring it", stack.getId()) continue extruder_stack = None intent_category = None if stack.getMetaDataEntry("position") is not None: extruder_stack = stack intent_category = stack.intent.getMetaDataEntry("intent_category") new_changes = self._createQualityChanges(quality_container.getMetaDataEntry("quality_type"), intent_category, unique_name, global_stack, extruder_stack) container_manager._performMerge(new_changes, quality_changes_container, clear_settings = False) container_manager._performMerge(new_changes, stack.userChanges) container_registry.addContainer(new_changes) def _createQualityChanges(self, quality_type: str, intent_category: Optional[str], new_name: str, machine: "GlobalStack", extruder_stack: Optional["ExtruderStack"]) -> "InstanceContainer": """Create a quality changes container with the given set-up. :param quality_type: The quality type of the new container. :param intent_category: The intent category of the new container. :param new_name: The name of the container. This name must be unique. :param machine: The global stack to create the profile for. :param extruder_stack: The extruder stack to create the profile for. If not provided, only a global container will be created. """ container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() base_id = machine.definition.getId() if extruder_stack is None else extruder_stack.getId() new_id = base_id + "_" + new_name new_id = new_id.lower().replace(" ", "_") new_id = container_registry.uniqueName(new_id) # Create a new quality_changes container for the quality. quality_changes = InstanceContainer(new_id) quality_changes.setName(new_name) quality_changes.setMetaDataEntry("type", "quality_changes") quality_changes.setMetaDataEntry("quality_type", quality_type) if intent_category is not None: quality_changes.setMetaDataEntry("intent_category", intent_category) # If we are creating a container for an extruder, ensure we add that to the container. if extruder_stack is not None: quality_changes.setMetaDataEntry("position", extruder_stack.getMetaDataEntry("position")) # If the machine specifies qualities should be filtered, ensure we match the current criteria. machine_definition_id = ContainerTree.getInstance().machines[machine.definition.getId()].quality_definition quality_changes.setDefinition(machine_definition_id) quality_changes.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.getInstance().SettingVersion) return quality_changes def _qualityChangesListChanged(self, container: "ContainerInterface") -> None: """Triggered when any container changed. This filters the updates to the container manager: When it applies to the list of quality changes, we need to update our list. """ if container.getMetaDataEntry("type") == "quality_changes": self._update() @pyqtSlot("QVariantMap", result = str) def getQualityItemDisplayName(self, quality_model_item: Dict[str, Any]) -> str: quality_group = quality_model_item["quality_group"] is_read_only = quality_model_item["is_read_only"] intent_category = quality_model_item["intent_category"] quality_level_name = "Not Supported" if quality_group is not None: quality_level_name = quality_group.name display_name = quality_level_name if intent_category != "default": intent_display_name = catalog.i18nc("@label", intent_category.capitalize()) display_name = "{intent_name} - {the_rest}".format(intent_name = intent_display_name, the_rest = display_name) # A custom quality if not is_read_only: display_name = "{custom_profile_name} - {the_rest}".format(custom_profile_name = quality_model_item["name"], the_rest = display_name) return display_name def _update(self): Logger.log("d", "Updating {model_class_name}.".format(model_class_name = self.__class__.__name__)) global_stack = self._machine_manager.activeMachine if not global_stack: self.setItems([]) return container_tree = ContainerTree.getInstance() quality_group_dict = container_tree.getCurrentQualityGroups() quality_changes_group_list = container_tree.getCurrentQualityChangesGroups() available_quality_types = set(quality_type for quality_type, quality_group in quality_group_dict.items() if quality_group.is_available) if not available_quality_types and not quality_changes_group_list: # Nothing to show self.setItems([]) return item_list = [] # Create quality group items (intent category = "default") for quality_group in quality_group_dict.values(): if not quality_group.is_available: continue layer_height = fetchLayerHeight(quality_group) item = {"name": quality_group.name, "is_read_only": True, "quality_group": quality_group, "quality_type": quality_group.quality_type, "quality_changes_group": None, "intent_category": "default", "section_name": catalog.i18nc("@label", "Default"), "layer_height": layer_height, # layer_height is only used for sorting } item_list.append(item) # Sort by layer_height for built-in qualities item_list = sorted(item_list, key = lambda x: x["layer_height"]) # Create intent items (non-default) available_intent_list = IntentManager.getInstance().getCurrentAvailableIntents() available_intent_list = [i for i in available_intent_list if i[0] != "default"] result = [] for intent_category, quality_type in available_intent_list: if not quality_group_dict[quality_type].is_available: continue result.append({ "name": quality_group_dict[quality_type].name, # Use the quality name as the display name "is_read_only": True, "quality_group": quality_group_dict[quality_type], "quality_type": quality_type, "quality_changes_group": None, "intent_category": intent_category, "section_name": catalog.i18nc("@label", intent_translations.get(intent_category, {}).get("name", catalog.i18nc("@label", "Unknown"))), }) # Sort by quality_type for each intent category intent_translations_list = list(intent_translations) def getIntentWeight(intent_category): try: return intent_translations_list.index(intent_category) except ValueError: return 99 result = sorted(result, key = lambda x: (getIntentWeight(x["intent_category"]), x["quality_type"])) item_list += result # Create quality_changes group items quality_changes_item_list = [] for quality_changes_group in quality_changes_group_list: # CURA-6913 Note that custom qualities can be based on "not supported", so the quality group can be None. quality_group = quality_group_dict.get(quality_changes_group.quality_type) quality_type = quality_changes_group.quality_type if not quality_changes_group.is_available: continue item = {"name": quality_changes_group.name, "is_read_only": False, "quality_group": quality_group, "quality_type": quality_type, "quality_changes_group": quality_changes_group, "intent_category": quality_changes_group.intent_category, "section_name": catalog.i18nc("@label", "Custom profiles"), } quality_changes_item_list.append(item) # Sort quality_changes items by names and append to the item list quality_changes_item_list = sorted(quality_changes_item_list, key = lambda x: x["name"].upper()) item_list += quality_changes_item_list self.setItems(item_list) @pyqtSlot(str, result = "QVariantList") def getFileNameFilters(self, io_type): """Gets a list of the possible file filters that the plugins have registered they can read or write. The convenience meta-filters "All Supported Types" and "All Files" are added when listing readers, but not when listing writers. :param io_type: name of the needed IO type :return: A list of strings indicating file name filters for a file dialog. TODO: Duplicated code here from InstanceContainersModel. Refactor and remove this later. """ from UM.i18n import i18nCatalog catalog = i18nCatalog("uranium") #TODO: This function should be in UM.Resources! filters = [] all_types = [] for plugin_id, meta_data in self._getIOPlugins(io_type): for io_plugin in meta_data[io_type]: filters.append(io_plugin["description"] + " (*." + io_plugin["extension"] + ")") all_types.append("*.{0}".format(io_plugin["extension"])) if "_reader" in io_type: # if we're listing readers, add the option to show all supported files as the default option filters.insert(0, catalog.i18nc("@item:inlistbox", "All Supported Types ({0})", " ".join(all_types))) filters.append(catalog.i18nc("@item:inlistbox", "All Files (*)")) # Also allow arbitrary files, if the user so prefers. return filters def _getIOPlugins(self, io_type): """Gets a list of profile reader or writer plugins :return: List of tuples of (plugin_id, meta_data). """ from UM.PluginRegistry import PluginRegistry pr = PluginRegistry.getInstance() active_plugin_ids = pr.getActivePlugins() result = [] for plugin_id in active_plugin_ids: meta_data = pr.getMetaData(plugin_id) if io_type in meta_data: result.append( (plugin_id, meta_data) ) return result