MaterialManager.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  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. import copy
  5. import uuid
  6. from typing import Dict, Optional, TYPE_CHECKING, Any, Set, List, cast, Tuple
  7. from PyQt5.Qt import QTimer, QObject, pyqtSignal, pyqtSlot
  8. from UM.Application import Application
  9. from UM.ConfigurationErrorMessage import ConfigurationErrorMessage
  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. from .MaterialNode import MaterialNode
  15. from .MaterialGroup import MaterialGroup
  16. from .VariantType import VariantType
  17. if TYPE_CHECKING:
  18. from UM.Settings.DefinitionContainer import DefinitionContainer
  19. from UM.Settings.InstanceContainer import InstanceContainer
  20. from cura.Settings.GlobalStack import GlobalStack
  21. from cura.Settings.ExtruderStack import ExtruderStack
  22. #
  23. # MaterialManager maintains a number of maps and trees for material lookup.
  24. # The models GUI and QML use are now only dependent on the MaterialManager. That means as long as the data in
  25. # MaterialManager gets updated correctly, the GUI models should be updated correctly too, and the same goes for GUI.
  26. #
  27. # For now, updating the lookup maps and trees here is very simple: we discard the old data completely and recreate them
  28. # again. This means the update is exactly the same as initialization. There are performance concerns about this approach
  29. # but so far the creation of the tables and maps is very fast and there is no noticeable slowness, we keep it like this
  30. # because it's simple.
  31. #
  32. class MaterialManager(QObject):
  33. materialsUpdated = pyqtSignal() # Emitted whenever the material lookup tables are updated.
  34. favoritesUpdated = pyqtSignal() # Emitted whenever the favorites are changed
  35. def __init__(self, container_registry, parent = None):
  36. super().__init__(parent)
  37. self._application = Application.getInstance()
  38. self._container_registry = container_registry # type: ContainerRegistry
  39. # Material_type -> generic material metadata
  40. self._fallback_materials_map = dict() # type: Dict[str, Dict[str, Any]]
  41. # Root_material_id -> MaterialGroup
  42. self._material_group_map = dict() # type: Dict[str, MaterialGroup]
  43. # Approximate diameter str
  44. self._diameter_machine_nozzle_buildplate_material_map = dict() # type: Dict[str, Dict[str, MaterialNode]]
  45. # We're using these two maps to convert between the specific diameter material id and the generic material id
  46. # because the generic material ids are used in qualities and definitions, while the specific diameter material is meant
  47. # i.e. generic_pla -> generic_pla_175
  48. # root_material_id -> approximate diameter str -> root_material_id for that diameter
  49. self._material_diameter_map = defaultdict(dict) # type: Dict[str, Dict[str, str]]
  50. # Material id including diameter (generic_pla_175) -> material root id (generic_pla)
  51. self._diameter_material_map = dict() # type: Dict[str, str]
  52. # This is used in Legacy UM3 send material function and the material management page.
  53. # GUID -> a list of material_groups
  54. self._guid_material_groups_map = defaultdict(list) # type: Dict[str, List[MaterialGroup]]
  55. # The machine definition ID for the non-machine-specific materials.
  56. # This is used as the last fallback option if the given machine-specific material(s) cannot be found.
  57. self._default_machine_definition_id = "fdmprinter"
  58. self._default_approximate_diameter_for_quality_search = "3"
  59. # When a material gets added/imported, there can be more than one InstanceContainers. In those cases, we don't
  60. # want to react on every container/metadata changed signal. The timer here is to buffer it a bit so we don't
  61. # react too many time.
  62. self._update_timer = QTimer(self)
  63. self._update_timer.setInterval(300)
  64. self._update_timer.setSingleShot(True)
  65. self._update_timer.timeout.connect(self._updateMaps)
  66. self._container_registry.containerMetaDataChanged.connect(self._onContainerMetadataChanged)
  67. self._container_registry.containerAdded.connect(self._onContainerMetadataChanged)
  68. self._container_registry.containerRemoved.connect(self._onContainerMetadataChanged)
  69. self._favorites = set() # type: Set[str]
  70. def initialize(self) -> None:
  71. # Find all materials and put them in a matrix for quick search.
  72. material_metadatas = {metadata["id"]: metadata for metadata in
  73. self._container_registry.findContainersMetadata(type = "material") if
  74. metadata.get("GUID")} # type: Dict[str, Dict[str, Any]]
  75. self._material_group_map = dict() # type: Dict[str, MaterialGroup]
  76. # Map #1
  77. # root_material_id -> MaterialGroup
  78. for material_id, material_metadata in material_metadatas.items():
  79. # We don't store empty material in the lookup tables
  80. if material_id == "empty_material":
  81. continue
  82. root_material_id = material_metadata.get("base_file", "")
  83. if root_material_id not in material_metadatas: #Not a registered material profile. Don't store this in the look-up tables.
  84. continue
  85. if root_material_id not in self._material_group_map:
  86. self._material_group_map[root_material_id] = MaterialGroup(root_material_id, MaterialNode(material_metadatas[root_material_id]))
  87. self._material_group_map[root_material_id].is_read_only = self._container_registry.isReadOnly(root_material_id)
  88. group = self._material_group_map[root_material_id]
  89. # Store this material in the group of the appropriate root material.
  90. if material_id != root_material_id:
  91. new_node = MaterialNode(material_metadata)
  92. group.derived_material_node_list.append(new_node)
  93. # Order this map alphabetically so it's easier to navigate in a debugger
  94. self._material_group_map = OrderedDict(sorted(self._material_group_map.items(), key = lambda x: x[0]))
  95. # Map #1.5
  96. # GUID -> material group list
  97. self._guid_material_groups_map = defaultdict(list) # type: Dict[str, List[MaterialGroup]]
  98. for root_material_id, material_group in self._material_group_map.items():
  99. guid = material_group.root_material_node.getMetaDataEntry("GUID", "")
  100. self._guid_material_groups_map[guid].append(material_group)
  101. # Map #2
  102. # Lookup table for material type -> fallback material metadata, only for read-only materials
  103. grouped_by_type_dict = dict() # type: Dict[str, Any]
  104. material_types_without_fallback = set()
  105. for root_material_id, material_node in self._material_group_map.items():
  106. material_type = material_node.root_material_node.getMetaDataEntry("material", "")
  107. if material_type not in grouped_by_type_dict:
  108. grouped_by_type_dict[material_type] = {"generic": None,
  109. "others": []}
  110. material_types_without_fallback.add(material_type)
  111. brand = material_node.root_material_node.getMetaDataEntry("brand", "")
  112. if brand.lower() == "generic":
  113. to_add = True
  114. if material_type in grouped_by_type_dict:
  115. diameter = material_node.root_material_node.getMetaDataEntry("approximate_diameter", "")
  116. if diameter != self._default_approximate_diameter_for_quality_search:
  117. to_add = False # don't add if it's not the default diameter
  118. if to_add:
  119. # Checking this first allow us to differentiate between not read only materials:
  120. # - if it's in the list, it means that is a new material without fallback
  121. # - if it is not, then it is a custom material with a fallback material (parent)
  122. if material_type in material_types_without_fallback:
  123. grouped_by_type_dict[material_type] = material_node.root_material_node._metadata
  124. material_types_without_fallback.remove(material_type)
  125. # Remove the materials that have no fallback materials
  126. for material_type in material_types_without_fallback:
  127. del grouped_by_type_dict[material_type]
  128. self._fallback_materials_map = grouped_by_type_dict
  129. # Map #3
  130. # There can be multiple material profiles for the same material with different diameters, such as "generic_pla"
  131. # and "generic_pla_175". This is inconvenient when we do material-specific quality lookup because a quality can
  132. # be for either "generic_pla" or "generic_pla_175", but not both. This map helps to get the correct material ID
  133. # for quality search.
  134. self._material_diameter_map = defaultdict(dict)
  135. self._diameter_material_map = dict()
  136. # Group the material IDs by the same name, material, brand, and color but with different diameters.
  137. material_group_dict = dict() # type: Dict[Tuple[Any], Dict[str, str]]
  138. keys_to_fetch = ("name", "material", "brand", "color")
  139. for root_material_id, machine_node in self._material_group_map.items():
  140. root_material_metadata = machine_node.root_material_node._metadata
  141. key_data_list = [] # type: List[Any]
  142. for key in keys_to_fetch:
  143. key_data_list.append(machine_node.root_material_node.getMetaDataEntry(key))
  144. key_data = cast(Tuple[Any], tuple(key_data_list)) # type: Tuple[Any]
  145. # If the key_data doesn't exist, it doesn't matter if the material is read only...
  146. if key_data not in material_group_dict:
  147. material_group_dict[key_data] = dict()
  148. else:
  149. # ...but if key_data exists, we just overwrite it if the material is read only, otherwise we skip it
  150. if not machine_node.is_read_only:
  151. continue
  152. approximate_diameter = machine_node.root_material_node.getMetaDataEntry("approximate_diameter", "")
  153. material_group_dict[key_data][approximate_diameter] = machine_node.root_material_node.getMetaDataEntry("id", "")
  154. # Map [root_material_id][diameter] -> root_material_id for this diameter
  155. for data_dict in material_group_dict.values():
  156. for root_material_id1 in data_dict.values():
  157. if root_material_id1 in self._material_diameter_map:
  158. continue
  159. diameter_map = data_dict
  160. for root_material_id2 in data_dict.values():
  161. self._material_diameter_map[root_material_id2] = diameter_map
  162. default_root_material_id = data_dict.get(self._default_approximate_diameter_for_quality_search)
  163. if default_root_material_id is None:
  164. default_root_material_id = list(data_dict.values())[0] # no default diameter present, just take "the" only one
  165. for root_material_id in data_dict.values():
  166. self._diameter_material_map[root_material_id] = default_root_material_id
  167. # Map #4
  168. # "machine" -> "nozzle name" -> "buildplate name" -> "root material ID" -> specific material InstanceContainer
  169. self._diameter_machine_nozzle_buildplate_material_map = dict() # type: Dict[str, Dict[str, MaterialNode]]
  170. for material_metadata in material_metadatas.values():
  171. self.__addMaterialMetadataIntoLookupTree(material_metadata)
  172. favorites = self._application.getPreferences().getValue("cura/favorite_materials")
  173. for item in favorites.split(";"):
  174. self._favorites.add(item)
  175. self.materialsUpdated.emit()
  176. def __addMaterialMetadataIntoLookupTree(self, material_metadata: Dict[str, Any]) -> None:
  177. material_id = material_metadata["id"]
  178. # We don't store empty material in the lookup tables
  179. if material_id == "empty_material":
  180. return
  181. root_material_id = material_metadata["base_file"]
  182. definition = material_metadata["definition"]
  183. approximate_diameter = str(material_metadata["approximate_diameter"])
  184. if approximate_diameter not in self._diameter_machine_nozzle_buildplate_material_map:
  185. self._diameter_machine_nozzle_buildplate_material_map[approximate_diameter] = {}
  186. machine_nozzle_buildplate_material_map = self._diameter_machine_nozzle_buildplate_material_map[
  187. approximate_diameter]
  188. if definition not in machine_nozzle_buildplate_material_map:
  189. machine_nozzle_buildplate_material_map[definition] = MaterialNode()
  190. # This is a list of information regarding the intermediate nodes:
  191. # nozzle -> buildplate
  192. nozzle_name = material_metadata.get("variant_name")
  193. buildplate_name = material_metadata.get("buildplate_name")
  194. intermediate_node_info_list = [(nozzle_name, VariantType.NOZZLE),
  195. (buildplate_name, VariantType.BUILD_PLATE),
  196. ]
  197. variant_manager = self._application.getVariantManager()
  198. machine_node = machine_nozzle_buildplate_material_map[definition]
  199. current_node = machine_node
  200. current_intermediate_node_info_idx = 0
  201. error_message = None # type: Optional[str]
  202. while current_intermediate_node_info_idx < len(intermediate_node_info_list):
  203. variant_name, variant_type = intermediate_node_info_list[current_intermediate_node_info_idx]
  204. if variant_name is not None:
  205. # The new material has a specific variant, so it needs to be added to that specific branch in the tree.
  206. variant = variant_manager.getVariantNode(definition, variant_name, variant_type)
  207. if variant is None:
  208. error_message = "Material {id} contains a variant {name} that does not exist.".format(
  209. id = material_metadata["id"], name = variant_name)
  210. break
  211. # Update the current node to advance to a more specific branch
  212. if variant_name not in current_node.children_map:
  213. current_node.children_map[variant_name] = MaterialNode()
  214. current_node = current_node.children_map[variant_name]
  215. current_intermediate_node_info_idx += 1
  216. if error_message is not None:
  217. Logger.log("e", "%s It will not be added into the material lookup tree.", error_message)
  218. self._container_registry.addWrongContainerId(material_metadata["id"])
  219. return
  220. # Add the material to the current tree node, which is the deepest (the most specific) branch we can find.
  221. # Sanity check: Make sure that there is no duplicated materials.
  222. if root_material_id in current_node.material_map:
  223. Logger.log("e", "Duplicated material [%s] with root ID [%s]. It has already been added.",
  224. material_id, root_material_id)
  225. ConfigurationErrorMessage.getInstance().addFaultyContainers(root_material_id)
  226. return
  227. current_node.material_map[root_material_id] = MaterialNode(material_metadata)
  228. def _updateMaps(self):
  229. Logger.log("i", "Updating material lookup data ...")
  230. self.initialize()
  231. def _onContainerMetadataChanged(self, container):
  232. self._onContainerChanged(container)
  233. def _onContainerChanged(self, container):
  234. container_type = container.getMetaDataEntry("type")
  235. if container_type != "material":
  236. return
  237. # update the maps
  238. self._update_timer.start()
  239. def getMaterialGroup(self, root_material_id: str) -> Optional[MaterialGroup]:
  240. return self._material_group_map.get(root_material_id)
  241. def getRootMaterialIDForDiameter(self, root_material_id: str, approximate_diameter: str) -> str:
  242. return self._material_diameter_map.get(root_material_id, {}).get(approximate_diameter, root_material_id)
  243. def getRootMaterialIDWithoutDiameter(self, root_material_id: str) -> str:
  244. return self._diameter_material_map.get(root_material_id, "")
  245. def getMaterialGroupListByGUID(self, guid: str) -> Optional[List[MaterialGroup]]:
  246. return self._guid_material_groups_map.get(guid)
  247. # Returns a dict of all material groups organized by root_material_id.
  248. def getAllMaterialGroups(self) -> Dict[str, "MaterialGroup"]:
  249. return self._material_group_map
  250. #
  251. # Return a dict with all root material IDs (k) and ContainerNodes (v) that's suitable for the given setup.
  252. #
  253. def getAvailableMaterials(self, machine_definition: "DefinitionContainer", nozzle_name: Optional[str],
  254. buildplate_name: Optional[str], diameter: float) -> Dict[str, MaterialNode]:
  255. # round the diameter to get the approximate diameter
  256. rounded_diameter = str(round(diameter))
  257. if rounded_diameter not in self._diameter_machine_nozzle_buildplate_material_map:
  258. Logger.log("i", "Cannot find materials with diameter [%s] (rounded to [%s])", diameter, rounded_diameter)
  259. return dict()
  260. machine_definition_id = machine_definition.getId()
  261. # If there are nozzle-and-or-buildplate materials, get the nozzle-and-or-buildplate material
  262. machine_nozzle_buildplate_material_map = self._diameter_machine_nozzle_buildplate_material_map[rounded_diameter]
  263. machine_node = machine_nozzle_buildplate_material_map.get(machine_definition_id)
  264. default_machine_node = machine_nozzle_buildplate_material_map.get(self._default_machine_definition_id)
  265. nozzle_node = None
  266. buildplate_node = None
  267. if nozzle_name is not None and machine_node is not None:
  268. nozzle_node = machine_node.getChildNode(nozzle_name)
  269. # Get buildplate node if possible
  270. if nozzle_node is not None and buildplate_name is not None:
  271. buildplate_node = nozzle_node.getChildNode(buildplate_name)
  272. nodes_to_check = [buildplate_node, nozzle_node, machine_node, default_machine_node]
  273. # Fallback mechanism of finding materials:
  274. # 1. buildplate-specific material
  275. # 2. nozzle-specific material
  276. # 3. machine-specific material
  277. # 4. generic material (for fdmprinter)
  278. machine_exclude_materials = machine_definition.getMetaDataEntry("exclude_materials", [])
  279. material_id_metadata_dict = dict() # type: Dict[str, MaterialNode]
  280. excluded_materials = set()
  281. for current_node in nodes_to_check:
  282. if current_node is None:
  283. continue
  284. # Only exclude the materials that are explicitly specified in the "exclude_materials" field.
  285. # Do not exclude other materials that are of the same type.
  286. for material_id, node in current_node.material_map.items():
  287. if material_id in machine_exclude_materials:
  288. excluded_materials.add(material_id)
  289. continue
  290. if material_id not in material_id_metadata_dict:
  291. material_id_metadata_dict[material_id] = node
  292. if excluded_materials:
  293. Logger.log("d", "Exclude materials {excluded_materials} for machine {machine_definition_id}".format(excluded_materials = ", ".join(excluded_materials), machine_definition_id = machine_definition_id))
  294. return material_id_metadata_dict
  295. #
  296. # A convenience function to get available materials for the given machine with the extruder position.
  297. #
  298. def getAvailableMaterialsForMachineExtruder(self, machine: "GlobalStack",
  299. extruder_stack: "ExtruderStack") -> Optional[Dict[str, MaterialNode]]:
  300. buildplate_name = machine.getBuildplateName()
  301. nozzle_name = None
  302. if extruder_stack.variant.getId() != "empty_variant":
  303. nozzle_name = extruder_stack.variant.getName()
  304. diameter = extruder_stack.getApproximateMaterialDiameter()
  305. # Fetch the available materials (ContainerNode) for the current active machine and extruder setup.
  306. return self.getAvailableMaterials(machine.definition, nozzle_name, buildplate_name, diameter)
  307. #
  308. # Gets MaterialNode for the given extruder and machine with the given material name.
  309. # Returns None if:
  310. # 1. the given machine doesn't have materials;
  311. # 2. cannot find any material InstanceContainers with the given settings.
  312. #
  313. def getMaterialNode(self, machine_definition_id: str, nozzle_name: Optional[str],
  314. buildplate_name: Optional[str], diameter: float, root_material_id: str) -> Optional["MaterialNode"]:
  315. # round the diameter to get the approximate diameter
  316. rounded_diameter = str(round(diameter))
  317. if rounded_diameter not in self._diameter_machine_nozzle_buildplate_material_map:
  318. Logger.log("i", "Cannot find materials with diameter [%s] (rounded to [%s]) for root material id [%s]",
  319. diameter, rounded_diameter, root_material_id)
  320. return None
  321. # If there are nozzle materials, get the nozzle-specific material
  322. machine_nozzle_buildplate_material_map = self._diameter_machine_nozzle_buildplate_material_map[rounded_diameter] # type: Dict[str, MaterialNode]
  323. machine_node = machine_nozzle_buildplate_material_map.get(machine_definition_id)
  324. nozzle_node = None
  325. buildplate_node = None
  326. # Fallback for "fdmprinter" if the machine-specific materials cannot be found
  327. if machine_node is None:
  328. machine_node = machine_nozzle_buildplate_material_map.get(self._default_machine_definition_id)
  329. if machine_node is not None and nozzle_name is not None:
  330. nozzle_node = machine_node.getChildNode(nozzle_name)
  331. if nozzle_node is not None and buildplate_name is not None:
  332. buildplate_node = nozzle_node.getChildNode(buildplate_name)
  333. # Fallback mechanism of finding materials:
  334. # 1. buildplate-specific material
  335. # 2. nozzle-specific material
  336. # 3. machine-specific material
  337. # 4. generic material (for fdmprinter)
  338. nodes_to_check = [buildplate_node, nozzle_node, machine_node,
  339. machine_nozzle_buildplate_material_map.get(self._default_machine_definition_id)]
  340. material_node = None
  341. for node in nodes_to_check:
  342. if node is not None:
  343. material_node = node.material_map.get(root_material_id)
  344. if material_node:
  345. break
  346. return material_node
  347. #
  348. # Gets MaterialNode for the given extruder and machine with the given material type.
  349. # Returns None if:
  350. # 1. the given machine doesn't have materials;
  351. # 2. cannot find any material InstanceContainers with the given settings.
  352. #
  353. def getMaterialNodeByType(self, global_stack: "GlobalStack", position: str, nozzle_name: str,
  354. buildplate_name: Optional[str], material_guid: str) -> Optional["MaterialNode"]:
  355. node = None
  356. machine_definition = global_stack.definition
  357. extruder_definition = global_stack.extruders[position].definition
  358. if parseBool(machine_definition.getMetaDataEntry("has_materials", False)):
  359. material_diameter = extruder_definition.getProperty("material_diameter", "value")
  360. if isinstance(material_diameter, SettingFunction):
  361. material_diameter = material_diameter(global_stack)
  362. # Look at the guid to material dictionary
  363. root_material_id = None
  364. for material_group in self._guid_material_groups_map[material_guid]:
  365. root_material_id = cast(str, material_group.root_material_node.getMetaDataEntry("id", ""))
  366. break
  367. if not root_material_id:
  368. Logger.log("i", "Cannot find materials with guid [%s] ", material_guid)
  369. return None
  370. node = self.getMaterialNode(machine_definition.getId(), nozzle_name, buildplate_name,
  371. material_diameter, root_material_id)
  372. return node
  373. # There are 2 ways to get fallback materials;
  374. # - A fallback by type (@sa getFallbackMaterialIdByMaterialType), which adds the generic version of this material
  375. # - A fallback by GUID; If a material has been duplicated, it should also check if the original materials do have
  376. # a GUID. This should only be done if the material itself does not have a quality just yet.
  377. def getFallBackMaterialIdsByMaterial(self, material: "InstanceContainer") -> List[str]:
  378. results = [] # type: List[str]
  379. material_groups = self.getMaterialGroupListByGUID(material.getMetaDataEntry("GUID"))
  380. for material_group in material_groups: # type: ignore
  381. if material_group.name != material.getId():
  382. # If the material in the group is read only, put it at the front of the list (since that is the most
  383. # likely one to get a result)
  384. if material_group.is_read_only:
  385. results.insert(0, material_group.name)
  386. else:
  387. results.append(material_group.name)
  388. fallback = self.getFallbackMaterialIdByMaterialType(material.getMetaDataEntry("material"))
  389. if fallback is not None:
  390. results.append(fallback)
  391. return results
  392. #
  393. # Used by QualityManager. Built-in quality profiles may be based on generic material IDs such as "generic_pla".
  394. # For materials such as ultimaker_pla_orange, no quality profiles may be found, so we should fall back to use
  395. # the generic material IDs to search for qualities.
  396. #
  397. # An example would be, suppose we have machine with preferred material set to "filo3d_pla" (1.75mm), but its
  398. # extruders only use 2.85mm materials, then we won't be able to find the preferred material for this machine.
  399. # A fallback would be to fetch a generic material of the same type "PLA" as "filo3d_pla", and in this case it will
  400. # be "generic_pla". This function is intended to get a generic fallback material for the given material type.
  401. #
  402. # This function returns the generic root material ID for the given material type, where material types are "PLA",
  403. # "ABS", etc.
  404. #
  405. def getFallbackMaterialIdByMaterialType(self, material_type: str) -> Optional[str]:
  406. # For safety
  407. if material_type not in self._fallback_materials_map:
  408. Logger.log("w", "The material type [%s] does not have a fallback material" % material_type)
  409. return None
  410. fallback_material = self._fallback_materials_map[material_type]
  411. if fallback_material:
  412. return self.getRootMaterialIDWithoutDiameter(fallback_material["id"])
  413. else:
  414. return None
  415. ## Get default material for given global stack, extruder position and extruder nozzle name
  416. # you can provide the extruder_definition and then the position is ignored (useful when building up global stack in CuraStackBuilder)
  417. def getDefaultMaterial(self, global_stack: "GlobalStack", position: str, nozzle_name: Optional[str],
  418. extruder_definition: Optional["DefinitionContainer"] = None) -> Optional["MaterialNode"]:
  419. node = None
  420. buildplate_name = global_stack.getBuildplateName()
  421. machine_definition = global_stack.definition
  422. # The extruder-compatible material diameter in the extruder definition may not be the correct value because
  423. # the user can change it in the definition_changes container.
  424. if extruder_definition is None:
  425. extruder_stack_or_definition = global_stack.extruders[position]
  426. is_extruder_stack = True
  427. else:
  428. extruder_stack_or_definition = extruder_definition
  429. is_extruder_stack = False
  430. if extruder_stack_or_definition and parseBool(global_stack.getMetaDataEntry("has_materials", False)):
  431. if is_extruder_stack:
  432. material_diameter = extruder_stack_or_definition.getCompatibleMaterialDiameter()
  433. else:
  434. material_diameter = extruder_stack_or_definition.getProperty("material_diameter", "value")
  435. if isinstance(material_diameter, SettingFunction):
  436. material_diameter = material_diameter(global_stack)
  437. approximate_material_diameter = str(round(material_diameter))
  438. root_material_id = machine_definition.getMetaDataEntry("preferred_material")
  439. root_material_id = self.getRootMaterialIDForDiameter(root_material_id, approximate_material_diameter)
  440. node = self.getMaterialNode(machine_definition.getId(), nozzle_name, buildplate_name,
  441. material_diameter, root_material_id)
  442. return node
  443. def removeMaterialByRootId(self, root_material_id: str):
  444. material_group = self.getMaterialGroup(root_material_id)
  445. if not material_group:
  446. Logger.log("i", "Unable to remove the material with id %s, because it doesn't exist.", root_material_id)
  447. return
  448. nodes_to_remove = [material_group.root_material_node] + material_group.derived_material_node_list
  449. # Sort all nodes with respect to the container ID lengths in the ascending order so the base material container
  450. # will be the first one to be removed. We need to do this to ensure that all containers get loaded & deleted.
  451. nodes_to_remove = sorted(nodes_to_remove, key = lambda x: len(x.getMetaDataEntry("id", "")))
  452. # Try to load all containers first. If there is any faulty ones, they will be put into the faulty container
  453. # list, so removeContainer() can ignore those ones.
  454. for node in nodes_to_remove:
  455. container_id = node.getMetaDataEntry("id", "")
  456. results = self._container_registry.findContainers(id = container_id)
  457. if not results:
  458. self._container_registry.addWrongContainerId(container_id)
  459. for node in nodes_to_remove:
  460. self._container_registry.removeContainer(node.getMetaDataEntry("id", ""))
  461. #
  462. # Methods for GUI
  463. #
  464. @pyqtSlot("QVariant", result=bool)
  465. def canMaterialBeRemoved(self, material_node: "MaterialNode"):
  466. # Check if the material is active in any extruder train. In that case, the material shouldn't be removed!
  467. # In the future we might enable this again, but right now, it's causing a ton of issues if we do (since it
  468. # corrupts the configuration)
  469. root_material_id = material_node.getMetaDataEntry("base_file")
  470. material_group = self.getMaterialGroup(root_material_id)
  471. if not material_group:
  472. return False
  473. nodes_to_remove = [material_group.root_material_node] + material_group.derived_material_node_list
  474. ids_to_remove = [node.getMetaDataEntry("id", "") for node in nodes_to_remove]
  475. for extruder_stack in self._container_registry.findContainerStacks(type="extruder_train"):
  476. if extruder_stack.material.getId() in ids_to_remove:
  477. return False
  478. return True
  479. @pyqtSlot("QVariant", str)
  480. def setMaterialName(self, material_node: "MaterialNode", name: str) -> None:
  481. root_material_id = material_node.getMetaDataEntry("base_file")
  482. if root_material_id is None:
  483. return
  484. if self._container_registry.isReadOnly(root_material_id):
  485. Logger.log("w", "Cannot set name of read-only container %s.", root_material_id)
  486. return
  487. material_group = self.getMaterialGroup(root_material_id)
  488. if material_group:
  489. container = material_group.root_material_node.getContainer()
  490. if container:
  491. container.setName(name)
  492. #
  493. # Removes the given material.
  494. #
  495. @pyqtSlot("QVariant")
  496. def removeMaterial(self, material_node: "MaterialNode") -> None:
  497. root_material_id = material_node.getMetaDataEntry("base_file")
  498. if root_material_id is not None:
  499. self.removeMaterialByRootId(root_material_id)
  500. #
  501. # Creates a duplicate of a material, which has the same GUID and base_file metadata.
  502. # Returns the root material ID of the duplicated material if successful.
  503. #
  504. @pyqtSlot("QVariant", result = str)
  505. def duplicateMaterial(self, material_node: MaterialNode, new_base_id: Optional[str] = None, new_metadata: Dict[str, Any] = None) -> Optional[str]:
  506. root_material_id = cast(str, material_node.getMetaDataEntry("base_file", ""))
  507. material_group = self.getMaterialGroup(root_material_id)
  508. if not material_group:
  509. Logger.log("i", "Unable to duplicate the material with id %s, because it doesn't exist.", root_material_id)
  510. return None
  511. base_container = material_group.root_material_node.getContainer()
  512. if not base_container:
  513. return None
  514. # Ensure all settings are saved.
  515. self._application.saveSettings()
  516. # Create a new ID & container to hold the data.
  517. new_containers = []
  518. if new_base_id is None:
  519. new_base_id = self._container_registry.uniqueName(base_container.getId())
  520. new_base_container = copy.deepcopy(base_container)
  521. new_base_container.getMetaData()["id"] = new_base_id
  522. new_base_container.getMetaData()["base_file"] = new_base_id
  523. if new_metadata is not None:
  524. for key, value in new_metadata.items():
  525. new_base_container.getMetaData()[key] = value
  526. new_containers.append(new_base_container)
  527. # Clone all of them.
  528. for node in material_group.derived_material_node_list:
  529. container_to_copy = node.getContainer()
  530. if not container_to_copy:
  531. continue
  532. # Create unique IDs for every clone.
  533. new_id = new_base_id
  534. if container_to_copy.getMetaDataEntry("definition") != "fdmprinter":
  535. new_id += "_" + container_to_copy.getMetaDataEntry("definition")
  536. if container_to_copy.getMetaDataEntry("variant_name"):
  537. nozzle_name = container_to_copy.getMetaDataEntry("variant_name")
  538. new_id += "_" + nozzle_name.replace(" ", "_")
  539. new_container = copy.deepcopy(container_to_copy)
  540. new_container.getMetaData()["id"] = new_id
  541. new_container.getMetaData()["base_file"] = new_base_id
  542. if new_metadata is not None:
  543. for key, value in new_metadata.items():
  544. new_container.getMetaData()[key] = value
  545. new_containers.append(new_container)
  546. for container_to_add in new_containers:
  547. container_to_add.setDirty(True)
  548. self._container_registry.addContainer(container_to_add)
  549. # if the duplicated material was favorite then the new material should also be added to favorite.
  550. if root_material_id in self.getFavorites():
  551. self.addFavorite(new_base_id)
  552. return new_base_id
  553. #
  554. # Create a new material by cloning Generic PLA for the current material diameter and generate a new GUID.
  555. # Returns the ID of the newly created material.
  556. @pyqtSlot(result = str)
  557. def createMaterial(self) -> str:
  558. from UM.i18n import i18nCatalog
  559. catalog = i18nCatalog("cura")
  560. # Ensure all settings are saved.
  561. self._application.saveSettings()
  562. machine_manager = self._application.getMachineManager()
  563. extruder_stack = machine_manager.activeStack
  564. machine_definition = self._application.getGlobalContainerStack().definition
  565. root_material_id = machine_definition.getMetaDataEntry("preferred_material", default = "generic_pla")
  566. approximate_diameter = str(extruder_stack.approximateMaterialDiameter)
  567. root_material_id = self.getRootMaterialIDForDiameter(root_material_id, approximate_diameter)
  568. material_group = self.getMaterialGroup(root_material_id)
  569. if not material_group: # This should never happen
  570. Logger.log("w", "Cannot get the material group of %s.", root_material_id)
  571. return ""
  572. # Create a new ID & container to hold the data.
  573. new_id = self._container_registry.uniqueName("custom_material")
  574. new_metadata = {"name": catalog.i18nc("@label", "Custom Material"),
  575. "brand": catalog.i18nc("@label", "Custom"),
  576. "GUID": str(uuid.uuid4()),
  577. }
  578. self.duplicateMaterial(material_group.root_material_node,
  579. new_base_id = new_id,
  580. new_metadata = new_metadata)
  581. return new_id
  582. @pyqtSlot(str)
  583. def addFavorite(self, root_material_id: str) -> None:
  584. self._favorites.add(root_material_id)
  585. self.materialsUpdated.emit()
  586. # Ensure all settings are saved.
  587. self._application.getPreferences().setValue("cura/favorite_materials", ";".join(list(self._favorites)))
  588. self._application.saveSettings()
  589. @pyqtSlot(str)
  590. def removeFavorite(self, root_material_id: str) -> None:
  591. try:
  592. self._favorites.remove(root_material_id)
  593. except KeyError:
  594. Logger.log("w", "Could not delete material %s from favorites as it was already deleted", root_material_id)
  595. return
  596. self.materialsUpdated.emit()
  597. # Ensure all settings are saved.
  598. self._application.getPreferences().setValue("cura/favorite_materials", ";".join(list(self._favorites)))
  599. self._application.saveSettings()
  600. @pyqtSlot()
  601. def getFavorites(self):
  602. return self._favorites