MaterialManagementModel.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. # Copyright (c) 2021 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import copy # To duplicate materials.
  4. from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl
  5. from typing import Any, Dict, Optional, TYPE_CHECKING
  6. import uuid # To generate new GUIDs for new materials.
  7. import zipfile # To export all materials in a .zip archive.
  8. from PyQt5.QtGui import QDesktopServices
  9. from UM.i18n import i18nCatalog
  10. from UM.Logger import Logger
  11. from UM.Message import Message
  12. from UM.Signal import postponeSignals, CompressTechnique
  13. import cura.CuraApplication # Imported like this to prevent circular imports.
  14. from cura.Machines.ContainerTree import ContainerTree
  15. from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To find the sets of materials belonging to each other, and currently loaded extruder stacks.
  16. if TYPE_CHECKING:
  17. from cura.Machines.MaterialNode import MaterialNode
  18. catalog = i18nCatalog("cura")
  19. class MaterialManagementModel(QObject):
  20. favoritesChanged = pyqtSignal(str)
  21. """Triggered when a favorite is added or removed.
  22. :param The base file of the material is provided as parameter when this emits
  23. """
  24. def __init__(self, parent: Optional[QObject] = None) -> None:
  25. super().__init__(parent = parent)
  26. self._checkIfNewMaterialsWereInstalled()
  27. def _checkIfNewMaterialsWereInstalled(self) -> None:
  28. """
  29. Checks whether new material packages were installed in the latest startup. If there were, then it shows
  30. a message prompting the user to sync the materials with their printers.
  31. """
  32. application = cura.CuraApplication.CuraApplication.getInstance()
  33. for package_id, package_data in application.getPackageManager().getPackagesInstalledOnStartup().items():
  34. if package_data["package_info"]["package_type"] == "material":
  35. # At least one new material was installed
  36. # TODO: This should be enabled again once CURA-8609 is merged
  37. #self._showSyncNewMaterialsMessage()
  38. break
  39. def _showSyncNewMaterialsMessage(self) -> None:
  40. sync_materials_message = Message(
  41. text = catalog.i18nc("@action:button",
  42. "Please sync the material profiles with your printers before starting to print."),
  43. title = catalog.i18nc("@action:button", "New materials installed"),
  44. message_type = Message.MessageType.WARNING,
  45. lifetime = 0
  46. )
  47. sync_materials_message.addAction(
  48. "sync",
  49. name = catalog.i18nc("@action:button", "Sync materials with printers"),
  50. icon = "",
  51. description = "Sync your newly installed materials with your printers.",
  52. button_align = Message.ActionButtonAlignment.ALIGN_RIGHT
  53. )
  54. sync_materials_message.addAction(
  55. "learn_more",
  56. name = catalog.i18nc("@action:button", "Learn more"),
  57. icon = "",
  58. description = "Learn more about syncing your newly installed materials with your printers.",
  59. button_align = Message.ActionButtonAlignment.ALIGN_LEFT,
  60. button_style = Message.ActionButtonStyle.LINK
  61. )
  62. sync_materials_message.actionTriggered.connect(self._onSyncMaterialsMessageActionTriggered)
  63. # Show the message only if there are printers that support material export
  64. container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
  65. global_stacks = container_registry.findContainerStacks(type = "machine")
  66. if any([stack.supportsMaterialExport for stack in global_stacks]):
  67. sync_materials_message.show()
  68. def _onSyncMaterialsMessageActionTriggered(self, sync_message: Message, sync_message_action: str):
  69. if sync_message_action == "sync":
  70. QDesktopServices.openUrl(QUrl("https://example.com/openSyncAllWindow"))
  71. # self.openSyncAllWindow()
  72. sync_message.hide()
  73. elif sync_message_action == "learn_more":
  74. QDesktopServices.openUrl(QUrl("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-printer-message"))
  75. @pyqtSlot("QVariant", result = bool)
  76. def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool:
  77. """Can a certain material be deleted, or is it still in use in one of the container stacks anywhere?
  78. We forbid the user from deleting a material if it's in use in any stack. Deleting it while it's in use can
  79. lead to corrupted stacks. In the future we might enable this functionality again (deleting the material from
  80. those stacks) but for now it is easier to prevent the user from doing this.
  81. :param material_node: The ContainerTree node of the material to check.
  82. :return: Whether or not the material can be removed.
  83. """
  84. container_registry = CuraContainerRegistry.getInstance()
  85. ids_to_remove = {metadata.get("id", "") for metadata in container_registry.findInstanceContainersMetadata(base_file = material_node.base_file)}
  86. for extruder_stack in container_registry.findContainerStacks(type = "extruder_train"):
  87. if extruder_stack.material.getId() in ids_to_remove:
  88. return False
  89. return True
  90. @pyqtSlot("QVariant", str)
  91. def setMaterialName(self, material_node: "MaterialNode", name: str) -> None:
  92. """Change the user-visible name of a material.
  93. :param material_node: The ContainerTree node of the material to rename.
  94. :param name: The new name for the material.
  95. """
  96. container_registry = CuraContainerRegistry.getInstance()
  97. root_material_id = material_node.base_file
  98. if container_registry.isReadOnly(root_material_id):
  99. Logger.log("w", "Cannot set name of read-only container %s.", root_material_id)
  100. return
  101. return container_registry.findContainers(id = root_material_id)[0].setName(name)
  102. @pyqtSlot("QVariant")
  103. def removeMaterial(self, material_node: "MaterialNode") -> None:
  104. """Deletes a material from Cura.
  105. This function does not do any safety checking any more. Please call this function only if:
  106. - The material is not read-only.
  107. - The material is not used in any stacks.
  108. If the material was not lazy-loaded yet, this will fully load the container. When removing this material
  109. node, all other materials with the same base fill will also be removed.
  110. :param material_node: The material to remove.
  111. """
  112. Logger.info(f"Removing material {material_node.container_id}")
  113. container_registry = CuraContainerRegistry.getInstance()
  114. materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file)
  115. # The material containers belonging to the same material file are supposed to work together. This postponeSignals()
  116. # does two things:
  117. # - optimizing the signal emitting.
  118. # - making sure that the signals will only be emitted after all the material containers have been removed.
  119. with postponeSignals(container_registry.containerRemoved, compress = CompressTechnique.CompressPerParameterValue):
  120. # CURA-6886: Some containers may not have been loaded. If remove one material container, its material file
  121. # will be removed. If later we remove a sub-material container which hasn't been loaded previously, it will
  122. # crash because removeContainer() requires to load the container first, but the material file was already
  123. # gone.
  124. for material_metadata in materials_this_base_file:
  125. container_registry.findInstanceContainers(id = material_metadata["id"])
  126. for material_metadata in materials_this_base_file:
  127. container_registry.removeContainer(material_metadata["id"])
  128. def duplicateMaterialByBaseFile(self, base_file: str, new_base_id: Optional[str] = None,
  129. new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]:
  130. """Creates a duplicate of a material with the same GUID and base_file metadata
  131. :param base_file: The base file of the material to duplicate.
  132. :param new_base_id: A new material ID for the base material. The IDs of the submaterials will be based off this
  133. one. If not provided, a material ID will be generated automatically.
  134. :param new_metadata: Metadata for the new material. If not provided, this will be duplicated from the original
  135. material.
  136. :return: The root material ID of the duplicate material.
  137. """
  138. container_registry = CuraContainerRegistry.getInstance()
  139. root_materials = container_registry.findContainers(id = base_file)
  140. if not root_materials:
  141. Logger.log("i", "Unable to duplicate the root material with ID {root_id}, because it doesn't exist.".format(root_id = base_file))
  142. return None
  143. root_material = root_materials[0]
  144. # Ensure that all settings are saved.
  145. application = cura.CuraApplication.CuraApplication.getInstance()
  146. application.saveSettings()
  147. # Create a new ID and container to hold the data.
  148. if new_base_id is None:
  149. new_base_id = container_registry.uniqueName(root_material.getId())
  150. new_root_material = copy.deepcopy(root_material)
  151. new_root_material.getMetaData()["id"] = new_base_id
  152. new_root_material.getMetaData()["base_file"] = new_base_id
  153. if new_metadata is not None:
  154. new_root_material.getMetaData().update(new_metadata)
  155. new_containers = [new_root_material]
  156. # Clone all submaterials.
  157. for container_to_copy in container_registry.findInstanceContainers(base_file = base_file):
  158. if container_to_copy.getId() == base_file:
  159. continue # We already have that one. Skip it.
  160. new_id = new_base_id
  161. definition = container_to_copy.getMetaDataEntry("definition")
  162. if definition != "fdmprinter":
  163. new_id += "_" + definition
  164. variant_name = container_to_copy.getMetaDataEntry("variant_name")
  165. if variant_name:
  166. new_id += "_" + variant_name.replace(" ", "_")
  167. new_container = copy.deepcopy(container_to_copy)
  168. new_container.getMetaData()["id"] = new_id
  169. new_container.getMetaData()["base_file"] = new_base_id
  170. if new_metadata is not None:
  171. new_container.getMetaData().update(new_metadata)
  172. new_containers.append(new_container)
  173. # CURA-6863: Nodes in ContainerTree will be updated upon ContainerAdded signals, one at a time. It will use the
  174. # best fit material container at the time it sees one. For example, if you duplicate and get generic_pva #2,
  175. # if the node update function sees the containers in the following order:
  176. #
  177. # - generic_pva #2
  178. # - generic_pva #2_um3_aa04
  179. #
  180. # It will first use "generic_pva #2" because that's the best fit it has ever seen, and later "generic_pva #2_um3_aa04"
  181. # once it sees that. Because things run in the Qt event loop, they don't happen at the same time. This means if
  182. # between those two events, the ContainerTree will have nodes that contain invalid data.
  183. #
  184. # This sort fixes the problem by emitting the most specific containers first.
  185. new_containers = sorted(new_containers, key = lambda x: x.getId(), reverse = True)
  186. # Optimization. Serving the same purpose as the postponeSignals() in removeMaterial()
  187. # postpone the signals emitted when duplicating materials. This is easier on the event loop; changes the
  188. # behavior to be like a transaction. Prevents concurrency issues.
  189. with postponeSignals(container_registry.containerAdded, compress=CompressTechnique.CompressPerParameterValue):
  190. for container_to_add in new_containers:
  191. container_to_add.setDirty(True)
  192. container_registry.addContainer(container_to_add)
  193. # If the duplicated material was favorite then the new material should also be added to the favorites.
  194. favorites_set = set(application.getPreferences().getValue("cura/favorite_materials").split(";"))
  195. if base_file in favorites_set:
  196. favorites_set.add(new_base_id)
  197. application.getPreferences().setValue("cura/favorite_materials", ";".join(favorites_set))
  198. return new_base_id
  199. @pyqtSlot("QVariant", result = str)
  200. def duplicateMaterial(self, material_node: "MaterialNode", new_base_id: Optional[str] = None,
  201. new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]:
  202. """Creates a duplicate of a material with the same GUID and base_file metadata
  203. :param material_node: The node representing the material to duplicate.
  204. :param new_base_id: A new material ID for the base material. The IDs of the submaterials will be based off this
  205. one. If not provided, a material ID will be generated automatically.
  206. :param new_metadata: Metadata for the new material. If not provided, this will be duplicated from the original
  207. material.
  208. :return: The root material ID of the duplicate material.
  209. """
  210. Logger.info(f"Duplicating material {material_node.base_file} to {new_base_id}")
  211. return self.duplicateMaterialByBaseFile(material_node.base_file, new_base_id, new_metadata)
  212. @pyqtSlot(result = str)
  213. def createMaterial(self) -> str:
  214. """Create a new material by cloning the preferred material for the current material diameter and generate a new
  215. GUID.
  216. The material type is explicitly left to be the one from the preferred material, since this allows the user to
  217. still have SOME profiles to work with.
  218. :return: The ID of the newly created material.
  219. """
  220. # Ensure all settings are saved.
  221. application = cura.CuraApplication.CuraApplication.getInstance()
  222. application.saveSettings()
  223. # Find the preferred material.
  224. extruder_stack = application.getMachineManager().activeStack
  225. active_variant_name = extruder_stack.variant.getName()
  226. approximate_diameter = int(extruder_stack.approximateMaterialDiameter)
  227. global_container_stack = application.getGlobalContainerStack()
  228. if not global_container_stack:
  229. return ""
  230. machine_node = ContainerTree.getInstance().machines[global_container_stack.definition.getId()]
  231. preferred_material_node = machine_node.variants[active_variant_name].preferredMaterial(approximate_diameter)
  232. # Create a new ID & new metadata for the new material.
  233. new_id = CuraContainerRegistry.getInstance().uniqueName("custom_material")
  234. new_metadata = {"name": catalog.i18nc("@label", "Custom Material"),
  235. "brand": catalog.i18nc("@label", "Custom"),
  236. "GUID": str(uuid.uuid4()),
  237. }
  238. self.duplicateMaterial(preferred_material_node, new_base_id = new_id, new_metadata = new_metadata)
  239. return new_id
  240. @pyqtSlot(str)
  241. def addFavorite(self, material_base_file: str) -> None:
  242. """Adds a certain material to the favorite materials.
  243. :param material_base_file: The base file of the material to add.
  244. """
  245. application = cura.CuraApplication.CuraApplication.getInstance()
  246. favorites = application.getPreferences().getValue("cura/favorite_materials").split(";")
  247. if material_base_file not in favorites:
  248. favorites.append(material_base_file)
  249. application.getPreferences().setValue("cura/favorite_materials", ";".join(favorites))
  250. application.saveSettings()
  251. self.favoritesChanged.emit(material_base_file)
  252. @pyqtSlot(str)
  253. def removeFavorite(self, material_base_file: str) -> None:
  254. """Removes a certain material from the favorite materials.
  255. If the material was not in the favorite materials, nothing happens.
  256. """
  257. application = cura.CuraApplication.CuraApplication.getInstance()
  258. favorites = application.getPreferences().getValue("cura/favorite_materials").split(";")
  259. try:
  260. favorites.remove(material_base_file)
  261. application.getPreferences().setValue("cura/favorite_materials", ";".join(favorites))
  262. application.saveSettings()
  263. self.favoritesChanged.emit(material_base_file)
  264. except ValueError: # Material was not in the favorites list.
  265. Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file))
  266. @pyqtSlot(result = QUrl)
  267. def getPreferredExportAllPath(self) -> QUrl:
  268. """
  269. Get the preferred path to export materials to.
  270. If there is a removable drive, that should be the preferred path. Otherwise it should be the most recent local
  271. file path.
  272. :return: The preferred path to export all materials to.
  273. """
  274. cura_application = cura.CuraApplication.CuraApplication.getInstance()
  275. device_manager = cura_application.getOutputDeviceManager()
  276. devices = device_manager.getOutputDevices()
  277. for device in devices:
  278. if device.__class__.__name__ == "RemovableDriveOutputDevice":
  279. return QUrl.fromLocalFile(device.getId())
  280. else: # No removable drives? Use local path.
  281. return cura_application.getDefaultPath("dialog_material_path")
  282. @pyqtSlot(QUrl)
  283. def exportAll(self, file_path: QUrl) -> None:
  284. """
  285. Export all materials to a certain file path.
  286. :param file_path: The path to export the materials to.
  287. """
  288. registry = CuraContainerRegistry.getInstance()
  289. try:
  290. archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED)
  291. except OSError as e:
  292. Logger.log("e", f"Can't write to destination {file_path.toLocalFile()}: {type(e)} - {str(e)}")
  293. error_message = Message(
  294. text = catalog.i18nc("@error:text Followed by an error message of why it could not save", "Could not save material archive to {filename}:").format(filename = file_path.toLocalFile()) + " " + str(e),
  295. title = catalog.i18nc("@error:title", "Failed to save material archive"),
  296. message_type = Message.MessageType.ERROR
  297. )
  298. error_message.show()
  299. return
  300. for metadata in registry.findInstanceContainersMetadata(type = "material"):
  301. if metadata["base_file"] != metadata["id"]: # Only process base files.
  302. continue
  303. if metadata["id"] == "empty_material": # Don't export the empty material.
  304. continue
  305. material = registry.findContainers(id = metadata["id"])[0]
  306. suffix = registry.getMimeTypeForContainer(type(material)).preferredSuffix
  307. filename = metadata["id"] + "." + suffix
  308. try:
  309. archive.writestr(filename, material.serialize())
  310. except OSError as e:
  311. Logger.log("e", f"An error has occurred while writing the material \'{metadata['id']}\' in the file \'{filename}\': {e}.")