# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import copy # To duplicate materials. from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot # To allow the preference page proxy to be used from the actual preferences page. from typing import Any, Dict, Optional, TYPE_CHECKING import uuid # To generate new GUIDs for new materials. from UM.i18n import i18nCatalog from UM.Logger import Logger from UM.Signal import postponeSignals, CompressTechnique import cura.CuraApplication # Imported like this to prevent circular imports. from cura.Machines.ContainerTree import ContainerTree from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To find the sets of materials belonging to each other, and currently loaded extruder stacks. if TYPE_CHECKING: from cura.Machines.MaterialNode import MaterialNode catalog = i18nCatalog("cura") ## Proxy class to the materials page in the preferences. # # This class handles the actions in that page, such as creating new materials, # renaming them, etc. class MaterialManagementModel(QObject): ## Triggered when a favorite is added or removed. # \param The base file of the material is provided as parameter when this # emits. favoritesChanged = pyqtSignal(str) ## Can a certain material be deleted, or is it still in use in one of the # container stacks anywhere? # # We forbid the user from deleting a material if it's in use in any stack. # Deleting it while it's in use can lead to corrupted stacks. In the # future we might enable this functionality again (deleting the material # from those stacks) but for now it is easier to prevent the user from # doing this. # \param material_node The ContainerTree node of the material to check. # \return Whether or not the material can be removed. @pyqtSlot("QVariant", result = bool) def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool: container_registry = CuraContainerRegistry.getInstance() ids_to_remove = {metadata.get("id", "") for metadata in container_registry.findInstanceContainersMetadata(base_file = material_node.base_file)} for extruder_stack in container_registry.findContainerStacks(type = "extruder_train"): if extruder_stack.material.getId() in ids_to_remove: return False return True ## Change the user-visible name of a material. # \param material_node The ContainerTree node of the material to rename. # \param name The new name for the material. @pyqtSlot("QVariant", str) def setMaterialName(self, material_node: "MaterialNode", name: str) -> None: container_registry = CuraContainerRegistry.getInstance() root_material_id = material_node.base_file if container_registry.isReadOnly(root_material_id): Logger.log("w", "Cannot set name of read-only container %s.", root_material_id) return return container_registry.findContainers(id = root_material_id)[0].setName(name) ## Deletes a material from Cura. # # This function does not do any safety checking any more. Please call this # function only if: # - The material is not read-only. # - The material is not used in any stacks. # If the material was not lazy-loaded yet, this will fully load the # container. When removing this material node, all other materials with # the same base fill will also be removed. # \param material_node The material to remove. @pyqtSlot("QVariant") def removeMaterial(self, material_node: "MaterialNode") -> None: container_registry = CuraContainerRegistry.getInstance() materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file) # The material containers belonging to the same material file are supposed to work together. This postponeSignals() # does two things: # - optimizing the signal emitting. # - making sure that the signals will only be emitted after all the material containers have been removed. with postponeSignals(container_registry.containerRemoved, compress = CompressTechnique.CompressPerParameterValue): # CURA-6886: Some containers may not have been loaded. If remove one material container, its material file # will be removed. If later we remove a sub-material container which hasn't been loaded previously, it will # crash because removeContainer() requires to load the container first, but the material file was already # gone. for material_metadata in materials_this_base_file: container_registry.findInstanceContainers(id = material_metadata["id"]) for material_metadata in materials_this_base_file: container_registry.removeContainer(material_metadata["id"]) ## Creates a duplicate of a material with the same GUID and base_file # metadata. # \param base_file: The base file of the material to duplicate. # \param new_base_id A new material ID for the base material. The IDs of # the submaterials will be based off this one. If not provided, a material # ID will be generated automatically. # \param new_metadata Metadata for the new material. If not provided, this # will be duplicated from the original material. # \return The root material ID of the duplicate material. def duplicateMaterialByBaseFile(self, base_file: str, new_base_id: Optional[str] = None, new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]: container_registry = CuraContainerRegistry.getInstance() root_materials = container_registry.findContainers(id = base_file) if not root_materials: Logger.log("i", "Unable to duplicate the root material with ID {root_id}, because it doesn't exist.".format(root_id = base_file)) return None root_material = root_materials[0] # Ensure that all settings are saved. application = cura.CuraApplication.CuraApplication.getInstance() application.saveSettings() # Create a new ID and container to hold the data. if new_base_id is None: new_base_id = container_registry.uniqueName(root_material.getId()) new_root_material = copy.deepcopy(root_material) new_root_material.getMetaData()["id"] = new_base_id new_root_material.getMetaData()["base_file"] = new_base_id if new_metadata is not None: new_root_material.getMetaData().update(new_metadata) new_containers = [new_root_material] # Clone all submaterials. for container_to_copy in container_registry.findInstanceContainers(base_file = base_file): if container_to_copy.getId() == base_file: continue # We already have that one. Skip it. new_id = new_base_id definition = container_to_copy.getMetaDataEntry("definition") if definition != "fdmprinter": new_id += "_" + definition variant_name = container_to_copy.getMetaDataEntry("variant_name") if variant_name: new_id += "_" + variant_name.replace(" ", "_") new_container = copy.deepcopy(container_to_copy) new_container.getMetaData()["id"] = new_id new_container.getMetaData()["base_file"] = new_base_id if new_metadata is not None: new_container.getMetaData().update(new_metadata) new_containers.append(new_container) # CURA-6863: Nodes in ContainerTree will be updated upon ContainerAdded signals, one at a time. It will use the # best fit material container at the time it sees one. For example, if you duplicate and get generic_pva #2, # if the node update function sees the containers in the following order: # # - generic_pva #2 # - generic_pva #2_um3_aa04 # # It will first use "generic_pva #2" because that's the best fit it has ever seen, and later "generic_pva #2_um3_aa04" # once it sees that. Because things run in the Qt event loop, they don't happen at the same time. This means if # between those two events, the ContainerTree will have nodes that contain invalid data. # # This sort fixes the problem by emitting the most specific containers first. new_containers = sorted(new_containers, key = lambda x: x.getId(), reverse = True) # Optimization. Serving the same purpose as the postponeSignals() in removeMaterial() # postpone the signals emitted when duplicating materials. This is easier on the event loop; changes the # behavior to be like a transaction. Prevents concurrency issues. with postponeSignals(container_registry.containerAdded, compress=CompressTechnique.CompressPerParameterValue): for container_to_add in new_containers: container_to_add.setDirty(True) container_registry.addContainer(container_to_add) # If the duplicated material was favorite then the new material should also be added to the favorites. favorites_set = set(application.getPreferences().getValue("cura/favorite_materials").split(";")) if base_file in favorites_set: favorites_set.add(new_base_id) application.getPreferences().setValue("cura/favorite_materials", ";".join(favorites_set)) return new_base_id ## Creates a duplicate of a material with the same GUID and base_file # metadata. # \param material_node The node representing the material to duplicate. # \param new_base_id A new material ID for the base material. The IDs of # the submaterials will be based off this one. If not provided, a material # ID will be generated automatically. # \param new_metadata Metadata for the new material. If not provided, this # will be duplicated from the original material. # \return The root material ID of the duplicate material. @pyqtSlot("QVariant", result = str) def duplicateMaterial(self, material_node: "MaterialNode", new_base_id: Optional[str] = None, new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]: return self.duplicateMaterialByBaseFile(material_node.base_file, new_base_id, new_metadata) ## Create a new material by cloning the preferred material for the current # material diameter and generate a new GUID. # # The material type is explicitly left to be the one from the preferred # material, since this allows the user to still have SOME profiles to work # with. # \return The ID of the newly created material. @pyqtSlot(result = str) def createMaterial(self) -> str: # Ensure all settings are saved. application = cura.CuraApplication.CuraApplication.getInstance() application.saveSettings() # Find the preferred material. extruder_stack = application.getMachineManager().activeStack active_variant_name = extruder_stack.variant.getName() approximate_diameter = int(extruder_stack.approximateMaterialDiameter) global_container_stack = application.getGlobalContainerStack() if not global_container_stack: return "" machine_node = ContainerTree.getInstance().machines[global_container_stack.definition.getId()] preferred_material_node = machine_node.variants[active_variant_name].preferredMaterial(approximate_diameter) # Create a new ID & new metadata for the new material. new_id = CuraContainerRegistry.getInstance().uniqueName("custom_material") new_metadata = {"name": catalog.i18nc("@label", "Custom Material"), "brand": catalog.i18nc("@label", "Custom"), "GUID": str(uuid.uuid4()), } self.duplicateMaterial(preferred_material_node, new_base_id = new_id, new_metadata = new_metadata) return new_id ## Adds a certain material to the favorite materials. # \param material_base_file The base file of the material to add. @pyqtSlot(str) def addFavorite(self, material_base_file: str) -> None: application = cura.CuraApplication.CuraApplication.getInstance() favorites = application.getPreferences().getValue("cura/favorite_materials").split(";") if material_base_file not in favorites: favorites.append(material_base_file) application.getPreferences().setValue("cura/favorite_materials", ";".join(favorites)) application.saveSettings() self.favoritesChanged.emit(material_base_file) ## Removes a certain material from the favorite materials. # # If the material was not in the favorite materials, nothing happens. @pyqtSlot(str) def removeFavorite(self, material_base_file: str) -> None: application = cura.CuraApplication.CuraApplication.getInstance() favorites = application.getPreferences().getValue("cura/favorite_materials").split(";") try: favorites.remove(material_base_file) application.getPreferences().setValue("cura/favorite_materials", ";".join(favorites)) application.saveSettings() self.favoritesChanged.emit(material_base_file) except ValueError: # Material was not in the favorites list. Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file))