MaterialManager.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. # Copyright (c) 2019 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. from collections import defaultdict
  4. import copy
  5. import uuid
  6. from typing import Dict, Optional, TYPE_CHECKING, Any, List, cast
  7. from PyQt5.Qt import QTimer, QObject, pyqtSignal, pyqtSlot
  8. from UM.ConfigurationErrorMessage import ConfigurationErrorMessage
  9. from UM.Decorators import deprecated
  10. from UM.Logger import Logger
  11. from UM.Settings.ContainerRegistry import ContainerRegistry
  12. from UM.Settings.SettingFunction import SettingFunction
  13. from UM.Util import parseBool
  14. import cura.CuraApplication #Imported like this to prevent circular imports.
  15. from cura.Machines.ContainerTree import ContainerTree
  16. from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
  17. from .MaterialNode import MaterialNode
  18. from .MaterialGroup import MaterialGroup
  19. from .VariantType import VariantType
  20. if TYPE_CHECKING:
  21. from UM.Settings.DefinitionContainer import DefinitionContainer
  22. from UM.Settings.InstanceContainer import InstanceContainer
  23. from cura.Settings.GlobalStack import GlobalStack
  24. from cura.Settings.ExtruderStack import ExtruderStack
  25. #
  26. # MaterialManager maintains a number of maps and trees for material lookup.
  27. # The models GUI and QML use are now only dependent on the MaterialManager. That means as long as the data in
  28. # MaterialManager gets updated correctly, the GUI models should be updated correctly too, and the same goes for GUI.
  29. #
  30. # For now, updating the lookup maps and trees here is very simple: we discard the old data completely and recreate them
  31. # again. This means the update is exactly the same as initialization. There are performance concerns about this approach
  32. # but so far the creation of the tables and maps is very fast and there is no noticeable slowness, we keep it like this
  33. # because it's simple.
  34. #
  35. class MaterialManager(QObject):
  36. __instance = None
  37. @classmethod
  38. @deprecated("Use the ContainerTree structure instead.", since = "4.3")
  39. def getInstance(cls) -> "MaterialManager":
  40. if cls.__instance is None:
  41. cls.__instance = MaterialManager()
  42. return cls.__instance
  43. materialsUpdated = pyqtSignal() # Emitted whenever the material lookup tables are updated.
  44. favoritesUpdated = pyqtSignal() # Emitted whenever the favorites are changed
  45. def __init__(self, parent = None):
  46. super().__init__(parent)
  47. # Material_type -> generic material metadata
  48. self._fallback_materials_map = dict() # type: Dict[str, Dict[str, Any]]
  49. # Root_material_id -> MaterialGroup
  50. self._material_group_map = dict() # type: Dict[str, MaterialGroup]
  51. # Material id including diameter (generic_pla_175) -> material root id (generic_pla)
  52. self._diameter_material_map = dict() # type: Dict[str, str]
  53. # This is used in Legacy UM3 send material function and the material management page.
  54. # GUID -> a list of material_groups
  55. self._guid_material_groups_map = defaultdict(list) # type: Dict[str, List[MaterialGroup]]
  56. self._favorites = set(cura.CuraApplication.CuraApplication.getInstance().getPreferences().getValue("cura/favorite_materials").split(";"))
  57. self.materialsUpdated.emit()
  58. self._update_timer = QTimer(self)
  59. self._update_timer.setInterval(300)
  60. self._update_timer.setSingleShot(True)
  61. self._update_timer.timeout.connect(self.materialsUpdated)
  62. container_registry = ContainerRegistry.getInstance()
  63. container_registry.containerMetaDataChanged.connect(self._onContainerMetadataChanged)
  64. container_registry.containerAdded.connect(self._onContainerMetadataChanged)
  65. container_registry.containerRemoved.connect(self._onContainerMetadataChanged)
  66. def _onContainerMetadataChanged(self, container):
  67. self._onContainerChanged(container)
  68. def _onContainerChanged(self, container):
  69. container_type = container.getMetaDataEntry("type")
  70. if container_type != "material":
  71. return
  72. # update the maps
  73. self._update_timer.start()
  74. def getMaterialGroup(self, root_material_id: str) -> Optional[MaterialGroup]:
  75. return self._material_group_map.get(root_material_id)
  76. def getRootMaterialIDForDiameter(self, root_material_id: str, approximate_diameter: str) -> str:
  77. original_material = CuraContainerRegistry.getInstance().findInstanceContainersMetadata(id=root_material_id)[0]
  78. if original_material["approximate_diameter"] == approximate_diameter:
  79. return root_material_id
  80. matching_materials = CuraContainerRegistry.getInstance().findInstanceContainersMetadata(type = "material", brand = original_material["brand"], definition = original_material["definition"], material = original_material["material"], color_name = original_material["color_name"])
  81. for material in matching_materials:
  82. if material["approximate_diameter"] == approximate_diameter:
  83. return material["id"]
  84. return root_material_id
  85. def getRootMaterialIDWithoutDiameter(self, root_material_id: str) -> str:
  86. return self._diameter_material_map.get(root_material_id, "")
  87. def getMaterialGroupListByGUID(self, guid: str) -> Optional[List[MaterialGroup]]:
  88. return self._guid_material_groups_map.get(guid)
  89. # Returns a dict of all material groups organized by root_material_id.
  90. def getAllMaterialGroups(self) -> Dict[str, "MaterialGroup"]:
  91. return self._material_group_map
  92. ## Gives a dictionary of all root material IDs and their associated
  93. # MaterialNodes from the ContainerTree that are available for the given
  94. # printer and variant.
  95. def getAvailableMaterials(self, definition_id: str, nozzle_name: Optional[str]) -> Dict[str, MaterialNode]:
  96. return ContainerTree.getInstance().machines[definition_id].variants[nozzle_name].materials
  97. #
  98. # A convenience function to get available materials for the given machine with the extruder position.
  99. #
  100. def getAvailableMaterialsForMachineExtruder(self, machine: "GlobalStack",
  101. extruder_stack: "ExtruderStack") -> Dict[str, MaterialNode]:
  102. nozzle_name = None
  103. if extruder_stack.variant.getId() != "empty_variant":
  104. nozzle_name = extruder_stack.variant.getName()
  105. # Fetch the available materials (ContainerNode) for the current active machine and extruder setup.
  106. materials = self.getAvailableMaterials(machine.definition.getId(), nozzle_name)
  107. compatible_material_diameter = str(round(extruder_stack.getCompatibleMaterialDiameter()))
  108. result = {key: material for key, material in materials.items() if material.container.getMetaDataEntry("approximate_diameter") == compatible_material_diameter}
  109. return result
  110. #
  111. # Gets MaterialNode for the given extruder and machine with the given material name.
  112. # Returns None if:
  113. # 1. the given machine doesn't have materials;
  114. # 2. cannot find any material InstanceContainers with the given settings.
  115. #
  116. def getMaterialNode(self, machine_definition_id: str, nozzle_name: Optional[str],
  117. buildplate_name: Optional[str], diameter: float, root_material_id: str) -> Optional["MaterialNode"]:
  118. container_tree = ContainerTree.getInstance()
  119. machine_node = container_tree.machines.get(machine_definition_id)
  120. if machine_node is None:
  121. Logger.log("w", "Could not find machine with definition %s in the container tree", machine_definition_id)
  122. return None
  123. variant_node = machine_node.variants.get(nozzle_name)
  124. if variant_node is None:
  125. Logger.log("w", "Could not find variant %s for machine with definition %s in the container tree", nozzle_name, machine_definition_id )
  126. return None
  127. material_node = variant_node.materials.get(root_material_id)
  128. if material_node is None:
  129. Logger.log("w", "Could not find material %s for machine with definition %s and variant %s in the container tree", root_material_id, machine_definition_id, nozzle_name)
  130. return None
  131. return material_node
  132. #
  133. # Gets MaterialNode for the given extruder and machine with the given material type.
  134. # Returns None if:
  135. # 1. the given machine doesn't have materials;
  136. # 2. cannot find any material InstanceContainers with the given settings.
  137. #
  138. def getMaterialNodeByType(self, global_stack: "GlobalStack", position: str, nozzle_name: str,
  139. buildplate_name: Optional[str], material_guid: str) -> Optional["MaterialNode"]:
  140. machine_definition = global_stack.definition
  141. variant_name = global_stack.extruders[position].variant.getName()
  142. return self.getMaterialNode(machine_definition.getId(), variant_name, buildplate_name, 3, material_guid)
  143. # There are 2 ways to get fallback materials;
  144. # - A fallback by type (@sa getFallbackMaterialIdByMaterialType), which adds the generic version of this material
  145. # - A fallback by GUID; If a material has been duplicated, it should also check if the original materials do have
  146. # a GUID. This should only be done if the material itself does not have a quality just yet.
  147. def getFallBackMaterialIdsByMaterial(self, material: "InstanceContainer") -> List[str]:
  148. results = [] # type: List[str]
  149. material_groups = self.getMaterialGroupListByGUID(material.getMetaDataEntry("GUID"))
  150. for material_group in material_groups: # type: ignore
  151. if material_group.name != material.getId():
  152. # If the material in the group is read only, put it at the front of the list (since that is the most
  153. # likely one to get a result)
  154. if material_group.is_read_only:
  155. results.insert(0, material_group.name)
  156. else:
  157. results.append(material_group.name)
  158. fallback = self.getFallbackMaterialIdByMaterialType(material.getMetaDataEntry("material"))
  159. if fallback is not None:
  160. results.append(fallback)
  161. return results
  162. #
  163. # Used by QualityManager. Built-in quality profiles may be based on generic material IDs such as "generic_pla".
  164. # For materials such as ultimaker_pla_orange, no quality profiles may be found, so we should fall back to use
  165. # the generic material IDs to search for qualities.
  166. #
  167. # An example would be, suppose we have machine with preferred material set to "filo3d_pla" (1.75mm), but its
  168. # extruders only use 2.85mm materials, then we won't be able to find the preferred material for this machine.
  169. # A fallback would be to fetch a generic material of the same type "PLA" as "filo3d_pla", and in this case it will
  170. # be "generic_pla". This function is intended to get a generic fallback material for the given material type.
  171. #
  172. # This function returns the generic root material ID for the given material type, where material types are "PLA",
  173. # "ABS", etc.
  174. #
  175. def getFallbackMaterialIdByMaterialType(self, material_type: str) -> Optional[str]:
  176. # For safety
  177. if material_type not in self._fallback_materials_map:
  178. Logger.log("w", "The material type [%s] does not have a fallback material" % material_type)
  179. return None
  180. fallback_material = self._fallback_materials_map[material_type]
  181. if fallback_material:
  182. return self.getRootMaterialIDWithoutDiameter(fallback_material["id"])
  183. else:
  184. return None
  185. ## Get default material for given global stack, extruder position and extruder nozzle name
  186. # you can provide the extruder_definition and then the position is ignored (useful when building up global stack in CuraStackBuilder)
  187. def getDefaultMaterial(self, global_stack: "GlobalStack", position: str, nozzle_name: Optional[str],
  188. extruder_definition: Optional["DefinitionContainer"] = None) -> "MaterialNode":
  189. definition_id = global_stack.definition.getId()
  190. machine_node = ContainerTree.getInstance().machines[definition_id]
  191. if nozzle_name in machine_node.variants:
  192. nozzle_node = machine_node.variants[nozzle_name]
  193. else:
  194. Logger.log("w", "Could not find variant {nozzle_name} for machine with definition {definition_id} in the container tree".format(nozzle_name = nozzle_name, definition_id = definition_id))
  195. nozzle_node = next(iter(machine_node.variants))
  196. if not parseBool(global_stack.getMetaDataEntry("has_materials", False)):
  197. return next(iter(nozzle_node.materials))
  198. if extruder_definition is not None:
  199. material_diameter = extruder_definition.getProperty("material_diameter", "value")
  200. else:
  201. material_diameter = global_stack.extruders[position].getCompatibleMaterialDiameter()
  202. approximate_material_diameter = round(material_diameter)
  203. return nozzle_node.preferredMaterial(approximate_material_diameter)
  204. def removeMaterialByRootId(self, root_material_id: str):
  205. container_registry = CuraContainerRegistry.getInstance()
  206. results = container_registry.findContainers(id=root_material_id)
  207. if not results:
  208. container_registry.addWrongContainerId(root_material_id)
  209. for result in results:
  210. container_registry.removeContainer(result.getMetaDataEntry("id", ""))
  211. @pyqtSlot("QVariant", result=bool)
  212. def canMaterialBeRemoved(self, material_node: "MaterialNode"):
  213. # Check if the material is active in any extruder train. In that case, the material shouldn't be removed!
  214. # In the future we might enable this again, but right now, it's causing a ton of issues if we do (since it
  215. # corrupts the configuration)
  216. root_material_id = material_node.container.getMetaDataEntry("base_file")
  217. ids_to_remove = [metadata.get("id", "") for metadata in CuraContainerRegistry.getInstance().findInstanceContainersMetadata(base_file=root_material_id)]
  218. for extruder_stack in CuraContainerRegistry.getInstance().findContainerStacks(type = "extruder_train"):
  219. if extruder_stack.material.getId() in ids_to_remove:
  220. return False
  221. return True
  222. @pyqtSlot("QVariant", str)
  223. def setMaterialName(self, material_node: "MaterialNode", name: str) -> None:
  224. root_material_id = material_node.container.getMetaDataEntry("base_file")
  225. if root_material_id is None:
  226. return
  227. if CuraContainerRegistry.getInstance().isReadOnly(root_material_id):
  228. Logger.log("w", "Cannot set name of read-only container %s.", root_material_id)
  229. return
  230. containers = CuraContainerRegistry.getInstance().findInstanceContainers(id = root_material_id)
  231. containers[0].setName(name)
  232. @pyqtSlot("QVariant")
  233. def removeMaterial(self, material_node: "MaterialNode") -> None:
  234. root_material_id = material_node.container.getMetaDataEntry("base_file")
  235. if root_material_id is not None:
  236. self.removeMaterialByRootId(root_material_id)
  237. def duplicateMaterialByRootId(self, root_material_id, new_base_id: Optional[str] = None, new_metadata: Dict[str, Any] = None) -> Optional[str]:
  238. container_registry = CuraContainerRegistry.getInstance()
  239. results = container_registry.findContainers(id=root_material_id)
  240. if not results:
  241. Logger.log("i", "Unable to duplicate the material with id %s, because it doesn't exist.", root_material_id)
  242. return None
  243. base_container = results[0]
  244. # Ensure all settings are saved.
  245. cura.CuraApplication.CuraApplication.getInstance().saveSettings()
  246. # Create a new ID & container to hold the data.
  247. new_containers = []
  248. container_registry = CuraContainerRegistry.getInstance()
  249. if new_base_id is None:
  250. new_base_id = container_registry.uniqueName(base_container.getId())
  251. new_base_container = copy.deepcopy(base_container)
  252. new_base_container.getMetaData()["id"] = new_base_id
  253. new_base_container.getMetaData()["base_file"] = new_base_id
  254. if new_metadata is not None:
  255. for key, value in new_metadata.items():
  256. new_base_container.getMetaData()[key] = value
  257. new_containers.append(new_base_container)
  258. # Clone all of them.
  259. for container_to_copy in container_registry.findContainers(base_file=root_material_id):
  260. if container_to_copy.getId() == root_material_id:
  261. continue # We already have that one, skip it
  262. new_id = new_base_id
  263. if container_to_copy.getMetaDataEntry("definition") != "fdmprinter":
  264. new_id += "_" + container_to_copy.getMetaDataEntry("definition")
  265. if container_to_copy.getMetaDataEntry("variant_name"):
  266. nozzle_name = container_to_copy.getMetaDataEntry("variant_name")
  267. new_id += "_" + nozzle_name.replace(" ", "_")
  268. new_container = copy.deepcopy(container_to_copy)
  269. new_container.getMetaData()["id"] = new_id
  270. new_container.getMetaData()["base_file"] = new_base_id
  271. if new_metadata is not None:
  272. for key, value in new_metadata.items():
  273. new_container.getMetaData()[key] = value
  274. new_containers.append(new_container)
  275. for container_to_add in new_containers:
  276. container_to_add.setDirty(True)
  277. container_registry.addContainer(container_to_add)
  278. # if the duplicated material was favorite then the new material should also be added to favorite.
  279. if root_material_id in self.getFavorites():
  280. self.addFavorite(new_base_id)
  281. return new_base_id
  282. #
  283. # Creates a duplicate of a material, which has the same GUID and base_file metadata.
  284. # Returns the root material ID of the duplicated material if successful.
  285. #
  286. @pyqtSlot("QVariant", result = str)
  287. def duplicateMaterial(self, material_node: MaterialNode, new_base_id: Optional[str] = None, new_metadata: Dict[str, Any] = None) -> Optional[str]:
  288. root_material_id = cast(str, material_node.container.getMetaDataEntry("base_file", ""))
  289. return self.duplicateMaterialByRootId(root_material_id, new_base_id, new_metadata)
  290. # Create a new material by cloning Generic PLA for the current material diameter and generate a new GUID.
  291. # Returns the ID of the newly created material.
  292. @pyqtSlot(result = str)
  293. def createMaterial(self) -> str:
  294. from UM.i18n import i18nCatalog
  295. catalog = i18nCatalog("cura")
  296. # Ensure all settings are saved.
  297. application = cura.CuraApplication.CuraApplication.getInstance()
  298. application.saveSettings()
  299. machine_manager = application.getMachineManager()
  300. extruder_stack = machine_manager.activeStack
  301. machine_definition = application.getGlobalContainerStack().definition
  302. root_material_id = machine_definition.getMetaDataEntry("preferred_material", default = "generic_pla")
  303. approximate_diameter = str(extruder_stack.approximateMaterialDiameter)
  304. root_material_id = self.getRootMaterialIDForDiameter(root_material_id, approximate_diameter)
  305. # Create a new ID & container to hold the data.
  306. new_id = CuraContainerRegistry.getInstance().uniqueName("custom_material")
  307. new_metadata = {"name": catalog.i18nc("@label", "Custom Material"),
  308. "brand": catalog.i18nc("@label", "Custom"),
  309. "GUID": str(uuid.uuid4()),
  310. }
  311. self.duplicateMaterialByRootId(root_material_id, new_base_id = new_id, new_metadata = new_metadata)
  312. return new_id
  313. @pyqtSlot(str)
  314. def addFavorite(self, root_material_id: str) -> None:
  315. self._favorites.add(root_material_id)
  316. self.materialsUpdated.emit()
  317. # Ensure all settings are saved.
  318. cura.CuraApplication.CuraApplication.getInstance().getPreferences().setValue("cura/favorite_materials", ";".join(list(self._favorites)))
  319. cura.CuraApplication.CuraApplication.getInstance().saveSettings()
  320. @pyqtSlot(str)
  321. def removeFavorite(self, root_material_id: str) -> None:
  322. try:
  323. self._favorites.remove(root_material_id)
  324. except KeyError:
  325. Logger.log("w", "Could not delete material %s from favorites as it was already deleted", root_material_id)
  326. return
  327. self.materialsUpdated.emit()
  328. # Ensure all settings are saved.
  329. cura.CuraApplication.CuraApplication.getInstance().getPreferences().setValue("cura/favorite_materials", ";".join(list(self._favorites)))
  330. cura.CuraApplication.CuraApplication.getInstance().saveSettings()
  331. @pyqtSlot()
  332. def getFavorites(self):
  333. return self._favorites