QualityManagementModel.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  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. # This is why we use the "supress_signals" flag for the set name. This basically makes the change silent.
  105. for metadata in quality_changes_group.metadata_per_extruder.values():
  106. extruder_container = cast(InstanceContainer, container_registry.findContainers(id = metadata["id"])[0])
  107. extruder_container.setName(new_name, supress_signals=True)
  108. global_container = cast(InstanceContainer, container_registry.findContainers(id = quality_changes_group.metadata_for_global["id"])[0])
  109. global_container.setName(new_name, supress_signals=True)
  110. quality_changes_group.name = new_name
  111. application.getMachineManager().activeQualityChanged.emit()
  112. application.getMachineManager().activeQualityGroupChanged.emit()
  113. return new_name
  114. @pyqtSlot(str, "QVariantMap")
  115. def duplicateQualityChanges(self, new_name: str, quality_model_item: Dict[str, Any]) -> None:
  116. """Duplicates a given quality profile OR quality changes profile.
  117. :param new_name: The desired name of the new profile. This will be made unique, so it might end up with a
  118. different name.
  119. :param quality_model_item: The item of this model to duplicate, as dictionary. See the descriptions of the
  120. roles of this list model.
  121. """
  122. global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
  123. if not global_stack:
  124. Logger.log("i", "No active global stack, cannot duplicate quality (changes) profile.")
  125. return
  126. container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
  127. new_name = container_registry.uniqueName(new_name)
  128. intent_category = quality_model_item["intent_category"]
  129. quality_group = quality_model_item["quality_group"]
  130. quality_changes_group = quality_model_item["quality_changes_group"]
  131. if quality_changes_group is None:
  132. new_quality_changes = self._createQualityChanges(quality_group.quality_type, intent_category, new_name,
  133. global_stack, extruder_stack = None)
  134. container_registry.addContainer(new_quality_changes)
  135. for extruder in global_stack.extruderList:
  136. new_extruder_quality_changes = self._createQualityChanges(quality_group.quality_type, intent_category,
  137. new_name,
  138. global_stack, extruder_stack = extruder)
  139. container_registry.addContainer(new_extruder_quality_changes)
  140. else:
  141. for metadata in [quality_changes_group.metadata_for_global] + list(quality_changes_group.metadata_per_extruder.values()):
  142. containers = container_registry.findContainers(id = metadata["id"])
  143. if not containers:
  144. continue
  145. container = containers[0]
  146. new_id = container_registry.uniqueName(container.getId())
  147. container_registry.addContainer(container.duplicate(new_id, new_name))
  148. @pyqtSlot(str)
  149. @pyqtSlot(str, bool)
  150. def createQualityChanges(self, base_name: str, activate_after_success: bool = False) -> None:
  151. """Create quality changes containers from the user containers in the active stacks.
  152. This will go through the global and extruder stacks and create quality_changes containers from the user
  153. containers in each stack. These then replace the quality_changes containers in the stack and clear the user
  154. settings.
  155. :param base_name: The new name for the quality changes profile. The final name of the profile might be
  156. different from this, because it needs to be made unique.
  157. """
  158. machine_manager = cura.CuraApplication.CuraApplication.getInstance().getMachineManager()
  159. global_stack = machine_manager.activeMachine
  160. if not global_stack:
  161. return
  162. active_quality_name = machine_manager.activeQualityOrQualityChangesName
  163. if active_quality_name == "":
  164. Logger.log("w", "No quality container found in stack %s, cannot create profile", global_stack.getId())
  165. return
  166. machine_manager.blurSettings.emit()
  167. if base_name is None or base_name == "":
  168. base_name = active_quality_name
  169. container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
  170. unique_name = container_registry.uniqueName(base_name)
  171. # Go through the active stacks and create quality_changes containers from the user containers.
  172. container_manager = ContainerManager.getInstance()
  173. stack_list = [global_stack] + global_stack.extruderList
  174. for stack in stack_list:
  175. quality_container = stack.quality
  176. quality_changes_container = stack.qualityChanges
  177. if not quality_container or not quality_changes_container:
  178. Logger.log("w", "No quality or quality changes container found in stack %s, ignoring it", stack.getId())
  179. continue
  180. extruder_stack = None
  181. intent_category = None
  182. if stack.getMetaDataEntry("position") is not None:
  183. extruder_stack = stack
  184. intent_category = stack.intent.getMetaDataEntry("intent_category")
  185. new_changes = self._createQualityChanges(quality_container.getMetaDataEntry("quality_type"), intent_category, unique_name, global_stack, extruder_stack)
  186. container_manager._performMerge(new_changes, quality_changes_container, clear_settings = False)
  187. container_manager._performMerge(new_changes, stack.userChanges)
  188. container_registry.addContainer(new_changes)
  189. if activate_after_success:
  190. # At this point, the QualityChangesGroup object for the new changes may not exist yet.
  191. # This can be forced by asking for all of them. At that point it's just as well to loop.
  192. for quality_changes in ContainerTree.getInstance().getCurrentQualityChangesGroups():
  193. if quality_changes.name == unique_name:
  194. machine_manager.setQualityChangesGroup(quality_changes)
  195. break
  196. def _createQualityChanges(self, quality_type: str, intent_category: Optional[str], new_name: str, machine: "GlobalStack", extruder_stack: Optional["ExtruderStack"]) -> "InstanceContainer":
  197. """Create a quality changes container with the given set-up.
  198. :param quality_type: The quality type of the new container.
  199. :param intent_category: The intent category of the new container.
  200. :param new_name: The name of the container. This name must be unique.
  201. :param machine: The global stack to create the profile for.
  202. :param extruder_stack: The extruder stack to create the profile for. If not provided, only a global container will be created.
  203. """
  204. container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
  205. base_id = machine.definition.getId() if extruder_stack is None else extruder_stack.getId()
  206. new_id = base_id + "_" + new_name
  207. new_id = new_id.lower().replace(" ", "_")
  208. new_id = container_registry.uniqueName(new_id)
  209. # Create a new quality_changes container for the quality.
  210. quality_changes = InstanceContainer(new_id)
  211. quality_changes.setName(new_name)
  212. quality_changes.setMetaDataEntry("type", "quality_changes")
  213. quality_changes.setMetaDataEntry("quality_type", quality_type)
  214. if intent_category is not None:
  215. quality_changes.setMetaDataEntry("intent_category", intent_category)
  216. # If we are creating a container for an extruder, ensure we add that to the container.
  217. if extruder_stack is not None:
  218. quality_changes.setMetaDataEntry("position", extruder_stack.getMetaDataEntry("position"))
  219. # If the machine specifies qualities should be filtered, ensure we match the current criteria.
  220. machine_definition_id = ContainerTree.getInstance().machines[machine.definition.getId()].quality_definition
  221. quality_changes.setDefinition(machine_definition_id)
  222. quality_changes.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.getInstance().SettingVersion)
  223. return quality_changes
  224. def _qualityChangesListChanged(self, container: "ContainerInterface") -> None:
  225. """Triggered when any container changed.
  226. This filters the updates to the container manager: When it applies to the list of quality changes, we need to
  227. update our list.
  228. """
  229. if container.getMetaDataEntry("type") == "quality_changes":
  230. self._update()
  231. @pyqtSlot("QVariantMap", result = str)
  232. def getQualityItemDisplayName(self, quality_model_item: Dict[str, Any]) -> str:
  233. quality_group = quality_model_item["quality_group"]
  234. is_read_only = quality_model_item["is_read_only"]
  235. intent_category = quality_model_item["intent_category"]
  236. quality_level_name = "Not Supported"
  237. if quality_group is not None:
  238. quality_level_name = quality_group.name
  239. display_name = quality_level_name
  240. if intent_category != "default":
  241. intent_display_name = catalog.i18nc("@label", intent_category.capitalize())
  242. display_name = "{intent_name} - {the_rest}".format(intent_name = intent_display_name,
  243. the_rest = display_name)
  244. # A custom quality
  245. if not is_read_only:
  246. display_name = "{custom_profile_name} - {the_rest}".format(custom_profile_name = quality_model_item["name"],
  247. the_rest = display_name)
  248. return display_name
  249. def _update(self):
  250. Logger.log("d", "Updating {model_class_name}.".format(model_class_name = self.__class__.__name__))
  251. global_stack = self._machine_manager.activeMachine
  252. if not global_stack:
  253. self.setItems([])
  254. return
  255. container_tree = ContainerTree.getInstance()
  256. quality_group_dict = container_tree.getCurrentQualityGroups()
  257. quality_changes_group_list = container_tree.getCurrentQualityChangesGroups()
  258. available_quality_types = set(quality_type for quality_type, quality_group in quality_group_dict.items()
  259. if quality_group.is_available)
  260. if not available_quality_types and not quality_changes_group_list:
  261. # Nothing to show
  262. self.setItems([])
  263. return
  264. item_list = []
  265. # Create quality group items (intent category = "default")
  266. for quality_group in quality_group_dict.values():
  267. if not quality_group.is_available:
  268. continue
  269. layer_height = fetchLayerHeight(quality_group)
  270. item = {"name": quality_group.name,
  271. "is_read_only": True,
  272. "quality_group": quality_group,
  273. "quality_type": quality_group.quality_type,
  274. "quality_changes_group": None,
  275. "intent_category": "default",
  276. "section_name": catalog.i18nc("@label", "Balanced"),
  277. "layer_height": layer_height, # layer_height is only used for sorting
  278. }
  279. item_list.append(item)
  280. # Sort by layer_height for built-in qualities
  281. item_list = sorted(item_list, key = lambda x: x["layer_height"])
  282. # Create intent items (non-default)
  283. available_intent_list = IntentManager.getInstance().getCurrentAvailableIntents()
  284. available_intent_list = [i for i in available_intent_list if i[0] != "default"]
  285. result = []
  286. for intent_category, quality_type in available_intent_list:
  287. if not quality_group_dict[quality_type].is_available:
  288. continue
  289. result.append({
  290. "name": quality_group_dict[quality_type].name, # Use the quality name as the display name
  291. "is_read_only": True,
  292. "quality_group": quality_group_dict[quality_type],
  293. "quality_type": quality_type,
  294. "quality_changes_group": None,
  295. "intent_category": intent_category,
  296. "section_name": catalog.i18nc("@label", intent_translations.get(intent_category, {}).get("name", catalog.i18nc("@label", intent_category.title()))),
  297. })
  298. # Sort by quality_type for each intent category
  299. intent_translations_list = list(intent_translations)
  300. def getIntentWeight(intent_category):
  301. try:
  302. return intent_translations_list.index(intent_category)
  303. except ValueError:
  304. return 99
  305. result = sorted(result, key = lambda x: (getIntentWeight(x["intent_category"]), x["quality_type"]))
  306. item_list += result
  307. # Create quality_changes group items
  308. quality_changes_item_list = []
  309. for quality_changes_group in quality_changes_group_list:
  310. # CURA-6913 Note that custom qualities can be based on "not supported", so the quality group can be None.
  311. quality_group = quality_group_dict.get(quality_changes_group.quality_type)
  312. quality_type = quality_changes_group.quality_type
  313. if not quality_changes_group.is_available:
  314. continue
  315. item = {"name": quality_changes_group.name,
  316. "is_read_only": False,
  317. "quality_group": quality_group,
  318. "quality_type": quality_type,
  319. "quality_changes_group": quality_changes_group,
  320. "intent_category": quality_changes_group.intent_category,
  321. "section_name": catalog.i18nc("@label", "Custom profiles"),
  322. }
  323. quality_changes_item_list.append(item)
  324. # Sort quality_changes items by names and append to the item list
  325. quality_changes_item_list = sorted(quality_changes_item_list, key = lambda x: x["name"].upper())
  326. item_list += quality_changes_item_list
  327. self.setItems(item_list)
  328. @pyqtSlot(str, result = "QVariantList")
  329. def getFileNameFilters(self, io_type):
  330. """Gets a list of the possible file filters that the plugins have registered they can read or write.
  331. The convenience meta-filters "All Supported Types" and "All Files" are added when listing readers,
  332. but not when listing writers.
  333. :param io_type: name of the needed IO type
  334. :return: A list of strings indicating file name filters for a file dialog.
  335. TODO: Duplicated code here from InstanceContainersModel. Refactor and remove this later.
  336. """
  337. from UM.i18n import i18nCatalog
  338. catalog = i18nCatalog("uranium")
  339. #TODO: This function should be in UM.Resources!
  340. filters = []
  341. all_types = []
  342. for plugin_id, meta_data in self._getIOPlugins(io_type):
  343. for io_plugin in meta_data[io_type]:
  344. filters.append(io_plugin["description"] + " (*." + io_plugin["extension"] + ")")
  345. all_types.append("*.{0}".format(io_plugin["extension"]))
  346. if "_reader" in io_type:
  347. # if we're listing readers, add the option to show all supported files as the default option
  348. filters.insert(0, catalog.i18nc("@item:inlistbox", "All Supported Types ({0})", " ".join(all_types)))
  349. filters.append(catalog.i18nc("@item:inlistbox", "All Files (*)")) # Also allow arbitrary files, if the user so prefers.
  350. return filters
  351. def _getIOPlugins(self, io_type):
  352. """Gets a list of profile reader or writer plugins
  353. :return: List of tuples of (plugin_id, meta_data).
  354. """
  355. from UM.PluginRegistry import PluginRegistry
  356. pr = PluginRegistry.getInstance()
  357. active_plugin_ids = pr.getActivePlugins()
  358. result = []
  359. for plugin_id in active_plugin_ids:
  360. meta_data = pr.getMetaData(plugin_id)
  361. if io_type in meta_data:
  362. result.append( (plugin_id, meta_data) )
  363. return result