QualityManagementModel.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. # Copyright (c) 2020 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. from typing import Any, cast, Dict, Optional, TYPE_CHECKING
  4. from PyQt6.QtCore import pyqtSlot, QObject, Qt, QTimer
  5. from UM.Logger import Logger
  6. from UM.Qt.ListModel import ListModel
  7. from UM.Settings.InstanceContainer import InstanceContainer # To create new profiles.
  8. import cura.CuraApplication # Imported this way to prevent circular imports.
  9. from cura.Settings.ContainerManager import ContainerManager
  10. from cura.Machines.ContainerTree import ContainerTree
  11. from cura.Settings.cura_empty_instance_containers import empty_quality_changes_container
  12. from cura.Settings.IntentManager import IntentManager
  13. from cura.Machines.Models.MachineModelUtils import fetchLayerHeight
  14. from cura.Machines.Models.IntentTranslations import intent_translations
  15. from UM.i18n import i18nCatalog
  16. catalog = i18nCatalog("cura")
  17. if TYPE_CHECKING:
  18. from UM.Settings.Interfaces import ContainerInterface
  19. from cura.Machines.QualityChangesGroup import QualityChangesGroup
  20. from cura.Settings.ExtruderStack import ExtruderStack
  21. from cura.Settings.GlobalStack import GlobalStack
  22. class QualityManagementModel(ListModel):
  23. """This the QML model for the quality management page."""
  24. NameRole = Qt.ItemDataRole.UserRole + 1
  25. IsReadOnlyRole = Qt.ItemDataRole.UserRole + 2
  26. QualityGroupRole = Qt.ItemDataRole.UserRole + 3
  27. QualityTypeRole = Qt.ItemDataRole.UserRole + 4
  28. QualityChangesGroupRole = Qt.ItemDataRole.UserRole + 5
  29. IntentCategoryRole = Qt.ItemDataRole.UserRole + 6
  30. SectionNameRole = Qt.ItemDataRole.UserRole + 7
  31. def __init__(self, parent: Optional["QObject"] = None) -> None:
  32. super().__init__(parent)
  33. self.addRoleName(self.NameRole, "name")
  34. self.addRoleName(self.IsReadOnlyRole, "is_read_only")
  35. self.addRoleName(self.QualityGroupRole, "quality_group")
  36. self.addRoleName(self.QualityTypeRole, "quality_type")
  37. self.addRoleName(self.QualityChangesGroupRole, "quality_changes_group")
  38. self.addRoleName(self.IntentCategoryRole, "intent_category")
  39. self.addRoleName(self.SectionNameRole, "section_name")
  40. application = cura.CuraApplication.CuraApplication.getInstance()
  41. container_registry = application.getContainerRegistry()
  42. self._machine_manager = application.getMachineManager()
  43. self._machine_manager.activeQualityGroupChanged.connect(self._onChange)
  44. self._machine_manager.activeStackChanged.connect(self._onChange)
  45. self._machine_manager.extruderChanged.connect(self._onChange)
  46. self._machine_manager.globalContainerChanged.connect(self._onChange)
  47. self._extruder_manager = application.getExtruderManager()
  48. self._extruder_manager.extrudersChanged.connect(self._onChange)
  49. container_registry.containerAdded.connect(self._qualityChangesListChanged)
  50. container_registry.containerRemoved.connect(self._qualityChangesListChanged)
  51. container_registry.containerMetaDataChanged.connect(self._qualityChangesListChanged)
  52. self._update_timer = QTimer()
  53. self._update_timer.setInterval(100)
  54. self._update_timer.setSingleShot(True)
  55. self._update_timer.timeout.connect(self._update)
  56. self._onChange()
  57. def _onChange(self) -> None:
  58. self._update_timer.start()
  59. @pyqtSlot(QObject)
  60. def removeQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup") -> None:
  61. """Deletes a custom profile. It will be gone forever.
  62. :param quality_changes_group: The quality changes group representing the profile to delete.
  63. """
  64. Logger.log("i", "Removing quality changes group {group_name}".format(group_name = quality_changes_group.name))
  65. removed_quality_changes_ids = set()
  66. container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
  67. for metadata in [quality_changes_group.metadata_for_global] + list(quality_changes_group.metadata_per_extruder.values()):
  68. container_id = metadata["id"]
  69. container_registry.removeContainer(container_id)
  70. removed_quality_changes_ids.add(container_id)
  71. # Reset all machines that have activated this custom profile.
  72. for global_stack in container_registry.findContainerStacks(type = "machine"):
  73. if global_stack.qualityChanges.getId() in removed_quality_changes_ids:
  74. global_stack.qualityChanges = empty_quality_changes_container
  75. for extruder_stack in container_registry.findContainerStacks(type = "extruder_train"):
  76. if extruder_stack.qualityChanges.getId() in removed_quality_changes_ids:
  77. extruder_stack.qualityChanges = empty_quality_changes_container
  78. @pyqtSlot(QObject, str, result = str)
  79. def renameQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup", new_name: str) -> str:
  80. """Rename a custom profile.
  81. Because the names must be unique, the new name may not actually become the name that was given. The actual
  82. name is returned by this function.
  83. :param quality_changes_group: The custom profile that must be renamed.
  84. :param new_name: The desired name for the profile.
  85. :return: The actual new name of the profile, after making the name unique.
  86. """
  87. Logger.log("i", "Renaming QualityChangesGroup {old_name} to {new_name}.".format(old_name = quality_changes_group.name, new_name = new_name))
  88. if new_name == quality_changes_group.name:
  89. Logger.log("i", "QualityChangesGroup name {name} unchanged.".format(name = quality_changes_group.name))
  90. return new_name
  91. application = cura.CuraApplication.CuraApplication.getInstance()
  92. container_registry = application.getContainerRegistry()
  93. new_name = container_registry.uniqueName(new_name)
  94. # CURA-6842
  95. # FIXME: setName() will trigger metaDataChanged signal that are connected with type Qt.AutoConnection. In this
  96. # case, setName() will trigger direct connections which in turn causes the quality changes group and the models
  97. # to update. Because multiple containers need to be renamed, and every time a container gets renamed, updates
  98. # gets triggered and this results in partial updates. For example, if we rename the global quality changes
  99. # container first, the rest of the system still thinks that I have selected "my_profile" instead of
  100. # "my_new_profile", but an update already gets triggered, and the quality changes group that's selected will
  101. # have no container for the global stack, because "my_profile" just got renamed to "my_new_profile". This results
  102. # in crashes because the rest of the system assumes that all data in a QualityChangesGroup will be correct.
  103. #
  104. # Renaming the container for the global stack in the end seems to be ok, because the assumption is mostly based
  105. # on the quality changes container for the global stack.
  106. for metadata in quality_changes_group.metadata_per_extruder.values():
  107. extruder_container = cast(InstanceContainer, container_registry.findContainers(id = metadata["id"])[0])
  108. extruder_container.setName(new_name)
  109. global_container = cast(InstanceContainer, container_registry.findContainers(id = quality_changes_group.metadata_for_global["id"])[0])
  110. global_container.setName(new_name)
  111. quality_changes_group.name = new_name
  112. application.getMachineManager().activeQualityChanged.emit()
  113. application.getMachineManager().activeQualityGroupChanged.emit()
  114. return new_name
  115. @pyqtSlot(str, "QVariantMap")
  116. def duplicateQualityChanges(self, new_name: str, quality_model_item: Dict[str, Any]) -> None:
  117. """Duplicates a given quality profile OR quality changes profile.
  118. :param new_name: The desired name of the new profile. This will be made unique, so it might end up with a
  119. different name.
  120. :param quality_model_item: The item of this model to duplicate, as dictionary. See the descriptions of the
  121. roles of this list model.
  122. """
  123. global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
  124. if not global_stack:
  125. Logger.log("i", "No active global stack, cannot duplicate quality (changes) profile.")
  126. return
  127. container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
  128. new_name = container_registry.uniqueName(new_name)
  129. intent_category = quality_model_item["intent_category"]
  130. quality_group = quality_model_item["quality_group"]
  131. quality_changes_group = quality_model_item["quality_changes_group"]
  132. if quality_changes_group is None:
  133. new_quality_changes = self._createQualityChanges(quality_group.quality_type, intent_category, new_name,
  134. global_stack, extruder_stack = None)
  135. container_registry.addContainer(new_quality_changes)
  136. for extruder in global_stack.extruderList:
  137. new_extruder_quality_changes = self._createQualityChanges(quality_group.quality_type, intent_category,
  138. new_name,
  139. global_stack, extruder_stack = extruder)
  140. container_registry.addContainer(new_extruder_quality_changes)
  141. else:
  142. for metadata in [quality_changes_group.metadata_for_global] + list(quality_changes_group.metadata_per_extruder.values()):
  143. containers = container_registry.findContainers(id = metadata["id"])
  144. if not containers:
  145. continue
  146. container = containers[0]
  147. new_id = container_registry.uniqueName(container.getId())
  148. container_registry.addContainer(container.duplicate(new_id, new_name))
  149. @pyqtSlot(str)
  150. @pyqtSlot(str, bool)
  151. def createQualityChanges(self, base_name: str, activate_after_success: bool = False) -> None:
  152. """Create quality changes containers from the user containers in the active stacks.
  153. This will go through the global and extruder stacks and create quality_changes containers from the user
  154. containers in each stack. These then replace the quality_changes containers in the stack and clear the user
  155. settings.
  156. :param base_name: The new name for the quality changes profile. The final name of the profile might be
  157. different from this, because it needs to be made unique.
  158. """
  159. machine_manager = cura.CuraApplication.CuraApplication.getInstance().getMachineManager()
  160. global_stack = machine_manager.activeMachine
  161. if not global_stack:
  162. return
  163. active_quality_name = machine_manager.activeQualityOrQualityChangesName
  164. if active_quality_name == "":
  165. Logger.log("w", "No quality container found in stack %s, cannot create profile", global_stack.getId())
  166. return
  167. machine_manager.blurSettings.emit()
  168. if base_name is None or base_name == "":
  169. base_name = active_quality_name
  170. container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
  171. unique_name = container_registry.uniqueName(base_name)
  172. # Go through the active stacks and create quality_changes containers from the user containers.
  173. container_manager = ContainerManager.getInstance()
  174. stack_list = [global_stack] + global_stack.extruderList
  175. for stack in stack_list:
  176. quality_container = stack.quality
  177. quality_changes_container = stack.qualityChanges
  178. if not quality_container or not quality_changes_container:
  179. Logger.log("w", "No quality or quality changes container found in stack %s, ignoring it", stack.getId())
  180. continue
  181. extruder_stack = None
  182. intent_category = None
  183. if stack.getMetaDataEntry("position") is not None:
  184. extruder_stack = stack
  185. intent_category = stack.intent.getMetaDataEntry("intent_category")
  186. new_changes = self._createQualityChanges(quality_container.getMetaDataEntry("quality_type"), intent_category, unique_name, global_stack, extruder_stack)
  187. container_manager._performMerge(new_changes, quality_changes_container, clear_settings = False)
  188. container_manager._performMerge(new_changes, stack.userChanges)
  189. container_registry.addContainer(new_changes)
  190. if activate_after_success:
  191. # At this point, the QualityChangesGroup object for the new changes may not exist yet.
  192. # This can be forced by asking for all of them. At that point it's just as well to loop.
  193. for quality_changes in ContainerTree.getInstance().getCurrentQualityChangesGroups():
  194. if quality_changes.name == unique_name:
  195. machine_manager.setQualityChangesGroup(quality_changes)
  196. break
  197. def _createQualityChanges(self, quality_type: str, intent_category: Optional[str], new_name: str, machine: "GlobalStack", extruder_stack: Optional["ExtruderStack"]) -> "InstanceContainer":
  198. """Create a quality changes container with the given set-up.
  199. :param quality_type: The quality type of the new container.
  200. :param intent_category: The intent category of the new container.
  201. :param new_name: The name of the container. This name must be unique.
  202. :param machine: The global stack to create the profile for.
  203. :param extruder_stack: The extruder stack to create the profile for. If not provided, only a global container will be created.
  204. """
  205. container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
  206. base_id = machine.definition.getId() if extruder_stack is None else extruder_stack.getId()
  207. new_id = base_id + "_" + new_name
  208. new_id = new_id.lower().replace(" ", "_")
  209. new_id = container_registry.uniqueName(new_id)
  210. # Create a new quality_changes container for the quality.
  211. quality_changes = InstanceContainer(new_id)
  212. quality_changes.setName(new_name)
  213. quality_changes.setMetaDataEntry("type", "quality_changes")
  214. quality_changes.setMetaDataEntry("quality_type", quality_type)
  215. if intent_category is not None:
  216. quality_changes.setMetaDataEntry("intent_category", intent_category)
  217. # If we are creating a container for an extruder, ensure we add that to the container.
  218. if extruder_stack is not None:
  219. quality_changes.setMetaDataEntry("position", extruder_stack.getMetaDataEntry("position"))
  220. # If the machine specifies qualities should be filtered, ensure we match the current criteria.
  221. machine_definition_id = ContainerTree.getInstance().machines[machine.definition.getId()].quality_definition
  222. quality_changes.setDefinition(machine_definition_id)
  223. quality_changes.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.getInstance().SettingVersion)
  224. return quality_changes
  225. def _qualityChangesListChanged(self, container: "ContainerInterface") -> None:
  226. """Triggered when any container changed.
  227. This filters the updates to the container manager: When it applies to the list of quality changes, we need to
  228. update our list.
  229. """
  230. if container.getMetaDataEntry("type") == "quality_changes":
  231. self._update()
  232. @pyqtSlot("QVariantMap", result = str)
  233. def getQualityItemDisplayName(self, quality_model_item: Dict[str, Any]) -> str:
  234. quality_group = quality_model_item["quality_group"]
  235. is_read_only = quality_model_item["is_read_only"]
  236. intent_category = quality_model_item["intent_category"]
  237. quality_level_name = "Not Supported"
  238. if quality_group is not None:
  239. quality_level_name = quality_group.name
  240. display_name = quality_level_name
  241. if intent_category != "default":
  242. intent_display_name = catalog.i18nc("@label", intent_category.capitalize())
  243. display_name = "{intent_name} - {the_rest}".format(intent_name = intent_display_name,
  244. the_rest = display_name)
  245. # A custom quality
  246. if not is_read_only:
  247. display_name = "{custom_profile_name} - {the_rest}".format(custom_profile_name = quality_model_item["name"],
  248. the_rest = display_name)
  249. return display_name
  250. def _update(self):
  251. Logger.log("d", "Updating {model_class_name}.".format(model_class_name = self.__class__.__name__))
  252. global_stack = self._machine_manager.activeMachine
  253. if not global_stack:
  254. self.setItems([])
  255. return
  256. container_tree = ContainerTree.getInstance()
  257. quality_group_dict = container_tree.getCurrentQualityGroups()
  258. quality_changes_group_list = container_tree.getCurrentQualityChangesGroups()
  259. available_quality_types = set(quality_type for quality_type, quality_group in quality_group_dict.items()
  260. if quality_group.is_available)
  261. if not available_quality_types and not quality_changes_group_list:
  262. # Nothing to show
  263. self.setItems([])
  264. return
  265. item_list = []
  266. # Create quality group items (intent category = "default")
  267. for quality_group in quality_group_dict.values():
  268. if not quality_group.is_available:
  269. continue
  270. layer_height = fetchLayerHeight(quality_group)
  271. item = {"name": quality_group.name,
  272. "is_read_only": True,
  273. "quality_group": quality_group,
  274. "quality_type": quality_group.quality_type,
  275. "quality_changes_group": None,
  276. "intent_category": "default",
  277. "section_name": catalog.i18nc("@label", "Balanced"),
  278. "layer_height": layer_height, # layer_height is only used for sorting
  279. }
  280. item_list.append(item)
  281. # Sort by layer_height for built-in qualities
  282. item_list = sorted(item_list, key = lambda x: x["layer_height"])
  283. # Create intent items (non-default)
  284. available_intent_list = IntentManager.getInstance().getCurrentAvailableIntents()
  285. available_intent_list = [i for i in available_intent_list if i[0] != "default"]
  286. result = []
  287. for intent_category, quality_type in available_intent_list:
  288. if not quality_group_dict[quality_type].is_available:
  289. continue
  290. result.append({
  291. "name": quality_group_dict[quality_type].name, # Use the quality name as the display name
  292. "is_read_only": True,
  293. "quality_group": quality_group_dict[quality_type],
  294. "quality_type": quality_type,
  295. "quality_changes_group": None,
  296. "intent_category": intent_category,
  297. "section_name": catalog.i18nc("@label", intent_translations.get(intent_category, {}).get("name", catalog.i18nc("@label", intent_category.title()))),
  298. })
  299. # Sort by quality_type for each intent category
  300. intent_translations_list = list(intent_translations)
  301. def getIntentWeight(intent_category):
  302. try:
  303. return intent_translations_list.index(intent_category)
  304. except ValueError:
  305. return 99
  306. result = sorted(result, key = lambda x: (getIntentWeight(x["intent_category"]), x["quality_type"]))
  307. item_list += result
  308. # Create quality_changes group items
  309. quality_changes_item_list = []
  310. for quality_changes_group in quality_changes_group_list:
  311. # CURA-6913 Note that custom qualities can be based on "not supported", so the quality group can be None.
  312. quality_group = quality_group_dict.get(quality_changes_group.quality_type)
  313. quality_type = quality_changes_group.quality_type
  314. if not quality_changes_group.is_available:
  315. continue
  316. item = {"name": quality_changes_group.name,
  317. "is_read_only": False,
  318. "quality_group": quality_group,
  319. "quality_type": quality_type,
  320. "quality_changes_group": quality_changes_group,
  321. "intent_category": quality_changes_group.intent_category,
  322. "section_name": catalog.i18nc("@label", "Custom profiles"),
  323. }
  324. quality_changes_item_list.append(item)
  325. # Sort quality_changes items by names and append to the item list
  326. quality_changes_item_list = sorted(quality_changes_item_list, key = lambda x: x["name"].upper())
  327. item_list += quality_changes_item_list
  328. self.setItems(item_list)
  329. @pyqtSlot(str, result = "QVariantList")
  330. def getFileNameFilters(self, io_type):
  331. """Gets a list of the possible file filters that the plugins have registered they can read or write.
  332. The convenience meta-filters "All Supported Types" and "All Files" are added when listing readers,
  333. but not when listing writers.
  334. :param io_type: name of the needed IO type
  335. :return: A list of strings indicating file name filters for a file dialog.
  336. TODO: Duplicated code here from InstanceContainersModel. Refactor and remove this later.
  337. """
  338. from UM.i18n import i18nCatalog
  339. catalog = i18nCatalog("uranium")
  340. #TODO: This function should be in UM.Resources!
  341. filters = []
  342. all_types = []
  343. for plugin_id, meta_data in self._getIOPlugins(io_type):
  344. for io_plugin in meta_data[io_type]:
  345. filters.append(io_plugin["description"] + " (*." + io_plugin["extension"] + ")")
  346. all_types.append("*.{0}".format(io_plugin["extension"]))
  347. if "_reader" in io_type:
  348. # if we're listing readers, add the option to show all supported files as the default option
  349. filters.insert(0, catalog.i18nc("@item:inlistbox", "All Supported Types ({0})", " ".join(all_types)))
  350. filters.append(catalog.i18nc("@item:inlistbox", "All Files (*)")) # Also allow arbitrary files, if the user so prefers.
  351. return filters
  352. def _getIOPlugins(self, io_type):
  353. """Gets a list of profile reader or writer plugins
  354. :return: List of tuples of (plugin_id, meta_data).
  355. """
  356. from UM.PluginRegistry import PluginRegistry
  357. pr = PluginRegistry.getInstance()
  358. active_plugin_ids = pr.getActivePlugins()
  359. result = []
  360. for plugin_id in active_plugin_ids:
  361. meta_data = pr.getMetaData(plugin_id)
  362. if io_type in meta_data:
  363. result.append( (plugin_id, meta_data) )
  364. return result