MaterialManager.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. # Copyright (c) 2018 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. from collections import defaultdict, OrderedDict
  4. from typing import Optional
  5. from PyQt5.Qt import QTimer, QObject, pyqtSignal
  6. from UM.Logger import Logger
  7. from UM.Settings import ContainerRegistry
  8. from .MaterialNode import MaterialNode
  9. from .MaterialGroup import MaterialGroup
  10. #
  11. # MaterialManager maintains a number of maps and trees for material lookup.
  12. # The models GUI and QML use are now only dependent on the MaterialManager. That means as long as the data in
  13. # MaterialManager gets updated correctly, the GUI models should be updated correctly too, and the same goes for GUI.
  14. #
  15. # For now, updating the lookup maps and trees here is very simple: we discard the old data completely and recreate them
  16. # again. This means the update is exactly the same as initialization. There are performance concerns about this approach
  17. # but so far the creation of the tables and maps is very fast and there is no noticeable slowness, we keep it like this
  18. # because it's simple.
  19. #
  20. class MaterialManager(QObject):
  21. materialsUpdated = pyqtSignal() # Emitted whenever the material lookup tables are updated.
  22. def __init__(self, container_registry, parent = None):
  23. super().__init__(parent)
  24. self._container_registry = container_registry # type: ContainerRegistry
  25. self._fallback_materials_map = dict() # material_type -> generic material metadata
  26. self._material_group_map = dict() # root_material_id -> MaterialGroup
  27. self._diameter_machine_variant_material_map = dict() # approximate diameter str -> dict(machine_definition_id -> MaterialNode)
  28. # We're using these two maps to convert between the specific diameter material id and the generic material id
  29. # because the generic material ids are used in qualities and definitions, while the specific diameter material is meant
  30. # i.e. generic_pla -> generic_pla_175
  31. self._material_diameter_map = defaultdict(dict) # root_material_id -> approximate diameter str -> root_material_id for that diameter
  32. self._diameter_material_map = dict() # material id including diameter (generic_pla_175) -> material root id (generic_pla)
  33. # This is used in Legacy UM3 send material function and the material management page.
  34. self._guid_material_groups_map = defaultdict(list) # GUID -> a list of material_groups
  35. # The machine definition ID for the non-machine-specific materials.
  36. # This is used as the last fallback option if the given machine-specific material(s) cannot be found.
  37. self._default_machine_definition_id = "fdmprinter"
  38. self._default_approximate_diameter_for_quality_search = "3"
  39. # When a material gets added/imported, there can be more than one InstanceContainers. In those cases, we don't
  40. # want to react on every container/metadata changed signal. The timer here is to buffer it a bit so we don't
  41. # react too many time.
  42. self._update_timer = QTimer(self)
  43. self._update_timer.setInterval(300)
  44. self._update_timer.setSingleShot(True)
  45. self._update_timer.timeout.connect(self._updateMaps)
  46. self._container_registry.containerMetaDataChanged.connect(self._onContainerMetadataChanged)
  47. self._container_registry.containerAdded.connect(self._onContainerMetadataChanged)
  48. self._container_registry.containerRemoved.connect(self._onContainerMetadataChanged)
  49. def initialize(self):
  50. # Find all materials and put them in a matrix for quick search.
  51. material_metadata_list = self._container_registry.findContainersMetadata(type = "material")
  52. self._material_group_map = dict()
  53. # Map #1
  54. # root_material_id -> MaterialGroup
  55. for material_metadata in material_metadata_list:
  56. material_id = material_metadata["id"]
  57. # We don't store empty material in the lookup tables
  58. if material_id == "empty_material":
  59. continue
  60. root_material_id = material_metadata.get("base_file")
  61. if root_material_id not in self._material_group_map:
  62. self._material_group_map[root_material_id] = MaterialGroup(root_material_id)
  63. group = self._material_group_map[root_material_id]
  64. # We only add root materials here
  65. if material_id == root_material_id:
  66. group.root_material_node = MaterialNode(material_metadata)
  67. else:
  68. new_node = MaterialNode(material_metadata)
  69. group.derived_material_node_list.append(new_node)
  70. # Order this map alphabetically so it's easier to navigate in a debugger
  71. self._material_group_map = OrderedDict(sorted(self._material_group_map.items(), key = lambda x: x[0]))
  72. # Map #1.5
  73. # GUID -> material group list
  74. self._guid_material_groups_map = defaultdict(list)
  75. for root_material_id, material_group in self._material_group_map.items():
  76. guid = material_group.root_material_node.metadata["GUID"]
  77. self._guid_material_groups_map[guid].append(material_group)
  78. # Map #2
  79. # Lookup table for material type -> fallback material metadata, only for read-only materials
  80. grouped_by_type_dict = dict()
  81. for root_material_id, material_node in self._material_group_map.items():
  82. if not self._container_registry.isReadOnly(root_material_id):
  83. continue
  84. material_type = material_node.root_material_node.metadata["material"]
  85. if material_type not in grouped_by_type_dict:
  86. grouped_by_type_dict[material_type] = {"generic": None,
  87. "others": []}
  88. brand = material_node.root_material_node.metadata["brand"]
  89. if brand.lower() == "generic":
  90. to_add = True
  91. if material_type in grouped_by_type_dict:
  92. diameter = material_node.root_material_node.metadata.get("approximate_diameter")
  93. if diameter != self._default_approximate_diameter_for_quality_search:
  94. to_add = False # don't add if it's not the default diameter
  95. if to_add:
  96. grouped_by_type_dict[material_type] = material_node.root_material_node.metadata
  97. self._fallback_materials_map = grouped_by_type_dict
  98. # Map #3
  99. # There can be multiple material profiles for the same material with different diameters, such as "generic_pla"
  100. # and "generic_pla_175". This is inconvenient when we do material-specific quality lookup because a quality can
  101. # be for either "generic_pla" or "generic_pla_175", but not both. This map helps to get the correct material ID
  102. # for quality search.
  103. self._material_diameter_map = defaultdict(dict)
  104. self._diameter_material_map = dict()
  105. # Group the material IDs by the same name, material, brand, and color but with different diameters.
  106. material_group_dict = dict()
  107. keys_to_fetch = ("name", "material", "brand", "color")
  108. for root_material_id, machine_node in self._material_group_map.items():
  109. if not self._container_registry.isReadOnly(root_material_id):
  110. continue
  111. root_material_metadata = machine_node.root_material_node.metadata
  112. key_data = []
  113. for key in keys_to_fetch:
  114. key_data.append(root_material_metadata.get(key))
  115. key_data = tuple(key_data)
  116. if key_data not in material_group_dict:
  117. material_group_dict[key_data] = dict()
  118. approximate_diameter = root_material_metadata.get("approximate_diameter")
  119. material_group_dict[key_data][approximate_diameter] = root_material_metadata["id"]
  120. # Map [root_material_id][diameter] -> root_material_id for this diameter
  121. for data_dict in material_group_dict.values():
  122. for root_material_id1 in data_dict.values():
  123. if root_material_id1 in self._material_diameter_map:
  124. continue
  125. diameter_map = data_dict
  126. for root_material_id2 in data_dict.values():
  127. self._material_diameter_map[root_material_id2] = diameter_map
  128. default_root_material_id = data_dict.get(self._default_approximate_diameter_for_quality_search)
  129. if default_root_material_id is None:
  130. default_root_material_id = list(data_dict.values())[0] # no default diameter present, just take "the" only one
  131. for root_material_id in data_dict.values():
  132. self._diameter_material_map[root_material_id] = default_root_material_id
  133. # Map #4
  134. # "machine" -> "variant_name" -> "root material ID" -> specific material InstanceContainer
  135. # Construct the "machine" -> "variant" -> "root material ID" -> specific material InstanceContainer
  136. self._diameter_machine_variant_material_map = dict()
  137. for material_metadata in material_metadata_list:
  138. # We don't store empty material in the lookup tables
  139. if material_metadata["id"] == "empty_material":
  140. continue
  141. root_material_id = material_metadata["base_file"]
  142. definition = material_metadata["definition"]
  143. approximate_diameter = material_metadata["approximate_diameter"]
  144. if approximate_diameter not in self._diameter_machine_variant_material_map:
  145. self._diameter_machine_variant_material_map[approximate_diameter] = {}
  146. machine_variant_material_map = self._diameter_machine_variant_material_map[approximate_diameter]
  147. if definition not in machine_variant_material_map:
  148. machine_variant_material_map[definition] = MaterialNode()
  149. machine_node = machine_variant_material_map[definition]
  150. variant_name = material_metadata.get("variant_name")
  151. if not variant_name:
  152. # if there is no variant, this material is for the machine, so put its metadata in the machine node.
  153. machine_node.material_map[root_material_id] = MaterialNode(material_metadata)
  154. else:
  155. # this material is variant-specific, so we save it in a variant-specific node under the
  156. # machine-specific node
  157. if variant_name not in machine_node.children_map:
  158. machine_node.children_map[variant_name] = MaterialNode()
  159. variant_node = machine_node.children_map[variant_name]
  160. if root_material_id not in variant_node.material_map:
  161. variant_node.material_map[root_material_id] = MaterialNode(material_metadata)
  162. else:
  163. # Sanity check: make sure we don't have duplicated variant-specific materials for the same machine
  164. raise RuntimeError("Found duplicate variant name [%s] for machine [%s] in material [%s]" %
  165. (variant_name, definition, material_metadata["id"]))
  166. self.materialsUpdated.emit()
  167. def _updateMaps(self):
  168. self.initialize()
  169. def _onContainerMetadataChanged(self, container):
  170. self._onContainerChanged(container)
  171. def _onContainerChanged(self, container):
  172. container_type = container.getMetaDataEntry("type")
  173. if container_type != "material":
  174. return
  175. # update the maps
  176. self._update_timer.start()
  177. def getMaterialGroup(self, root_material_id: str) -> Optional[MaterialGroup]:
  178. return self._material_group_map.get(root_material_id)
  179. def getRootMaterialIDForDiameter(self, root_material_id: str, approximate_diameter: str) -> str:
  180. return self._material_diameter_map.get(root_material_id).get(approximate_diameter, root_material_id)
  181. def getRootMaterialIDWithoutDiameter(self, root_material_id: str) -> str:
  182. return self._diameter_material_map.get(root_material_id)
  183. def getMaterialGroupListByGUID(self, guid: str) -> Optional[list]:
  184. return self._guid_material_groups_map.get(guid)
  185. #
  186. # Return a dict with all root material IDs (k) and ContainerNodes (v) that's suitable for the given setup.
  187. #
  188. def getAvailableMaterials(self, machine_definition_id: str, extruder_variant_name: Optional[str],
  189. diameter: float) -> dict:
  190. # round the diameter to get the approximate diameter
  191. rounded_diameter = str(round(diameter))
  192. if rounded_diameter not in self._diameter_machine_variant_material_map:
  193. Logger.log("i", "Cannot find materials with diameter [%s] (rounded to [%s])", diameter, rounded_diameter)
  194. return dict()
  195. # If there are variant materials, get the variant material
  196. machine_variant_material_map = self._diameter_machine_variant_material_map[rounded_diameter]
  197. machine_node = machine_variant_material_map.get(machine_definition_id)
  198. default_machine_node = machine_variant_material_map.get(self._default_machine_definition_id)
  199. variant_node = None
  200. if extruder_variant_name is not None and machine_node is not None:
  201. variant_node = machine_node.getChildNode(extruder_variant_name)
  202. nodes_to_check = [variant_node, machine_node, default_machine_node]
  203. # Fallback mechanism of finding materials:
  204. # 1. variant-specific material
  205. # 2. machine-specific material
  206. # 3. generic material (for fdmprinter)
  207. material_id_metadata_dict = dict()
  208. for node in nodes_to_check:
  209. if node is not None:
  210. for material_id, node in node.material_map.items():
  211. if material_id not in material_id_metadata_dict:
  212. material_id_metadata_dict[material_id] = node
  213. return material_id_metadata_dict
  214. #
  215. # Gets MaterialNode for the given extruder and machine with the given material name.
  216. # Returns None if:
  217. # 1. the given machine doesn't have materials;
  218. # 2. cannot find any material InstanceContainers with the given settings.
  219. #
  220. def getMaterialNode(self, machine_definition_id: str, extruder_variant_name: Optional[str],
  221. diameter: float, root_material_id: str) -> Optional["InstanceContainer"]:
  222. # round the diameter to get the approximate diameter
  223. rounded_diameter = str(round(diameter))
  224. if rounded_diameter not in self._diameter_machine_variant_material_map:
  225. Logger.log("i", "Cannot find materials with diameter [%s] (rounded to [%s]) for root material id [%s]",
  226. diameter, rounded_diameter, root_material_id)
  227. return None
  228. # If there are variant materials, get the variant material
  229. machine_variant_material_map = self._diameter_machine_variant_material_map[rounded_diameter]
  230. machine_node = machine_variant_material_map.get(machine_definition_id)
  231. variant_node = None
  232. # Fallback for "fdmprinter" if the machine-specific materials cannot be found
  233. if machine_node is None:
  234. machine_node = machine_variant_material_map.get(self._default_machine_definition_id)
  235. if machine_node is not None and extruder_variant_name is not None:
  236. variant_node = machine_node.getChildNode(extruder_variant_name)
  237. # Fallback mechanism of finding materials:
  238. # 1. variant-specific material
  239. # 2. machine-specific material
  240. # 3. generic material (for fdmprinter)
  241. nodes_to_check = [variant_node, machine_node,
  242. machine_variant_material_map.get(self._default_machine_definition_id)]
  243. material_node = None
  244. for node in nodes_to_check:
  245. if node is not None:
  246. material_node = node.material_map.get(root_material_id)
  247. if material_node:
  248. break
  249. return material_node
  250. #
  251. # Used by QualityManager. Built-in quality profiles may be based on generic material IDs such as "generic_pla".
  252. # For materials such as ultimaker_pla_orange, no quality profiles may be found, so we should fall back to use
  253. # the generic material IDs to search for qualities.
  254. #
  255. # This function returns the generic root material ID for the given material type, where material types are "PLA",
  256. # "ABS", etc.
  257. #
  258. def getFallbackMaterialId(self, material_type: str) -> str:
  259. # For safety
  260. if material_type not in self._fallback_materials_map:
  261. Logger.log("w", "The material type [%s] does not have a fallback material" % material_type)
  262. return None
  263. fallback_material = self._fallback_materials_map[material_type]
  264. if fallback_material:
  265. return self.getRootMaterialIDWithoutDiameter(fallback_material["id"])
  266. else:
  267. return None