1# Copyright (c) 2019 Ultimaker B.V.
2# Cura is released under the terms of the LGPLv3 or higher.
3
4import copy  # To duplicate materials.
5from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot  # To allow the preference page proxy to be used from the actual preferences page.
6from typing import Any, Dict, Optional, TYPE_CHECKING
7import uuid  # To generate new GUIDs for new materials.
8
9from UM.i18n import i18nCatalog
10from UM.Logger import Logger
11from UM.Signal import postponeSignals, CompressTechnique
12
13import cura.CuraApplication  # Imported like this to prevent circular imports.
14from cura.Machines.ContainerTree import ContainerTree
15from cura.Settings.CuraContainerRegistry import CuraContainerRegistry  # To find the sets of materials belonging to each other, and currently loaded extruder stacks.
16
17if TYPE_CHECKING:
18    from cura.Machines.MaterialNode import MaterialNode
19
20catalog = i18nCatalog("cura")
21
22class MaterialManagementModel(QObject):
23    """Proxy class to the materials page in the preferences.
24
25    This class handles the actions in that page, such as creating new materials, renaming them, etc.
26    """
27
28    favoritesChanged = pyqtSignal(str)
29    """Triggered when a favorite is added or removed.
30
31    :param The base file of the material is provided as parameter when this emits
32    """
33
34    @pyqtSlot("QVariant", result = bool)
35    def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool:
36        """Can a certain material be deleted, or is it still in use in one of the container stacks anywhere?
37
38        We forbid the user from deleting a material if it's in use in any stack. Deleting it while it's in use can
39        lead to corrupted stacks. In the future we might enable this functionality again (deleting the material from
40        those stacks) but for now it is easier to prevent the user from doing this.
41
42        :param material_node: The ContainerTree node of the material to check.
43
44        :return: Whether or not the material can be removed.
45        """
46
47        container_registry = CuraContainerRegistry.getInstance()
48        ids_to_remove = {metadata.get("id", "") for metadata in container_registry.findInstanceContainersMetadata(base_file = material_node.base_file)}
49        for extruder_stack in container_registry.findContainerStacks(type = "extruder_train"):
50            if extruder_stack.material.getId() in ids_to_remove:
51                return False
52        return True
53
54    @pyqtSlot("QVariant", str)
55    def setMaterialName(self, material_node: "MaterialNode", name: str) -> None:
56        """Change the user-visible name of a material.
57
58        :param material_node: The ContainerTree node of the material to rename.
59        :param name: The new name for the material.
60        """
61
62        container_registry = CuraContainerRegistry.getInstance()
63        root_material_id = material_node.base_file
64        if container_registry.isReadOnly(root_material_id):
65            Logger.log("w", "Cannot set name of read-only container %s.", root_material_id)
66            return
67        return container_registry.findContainers(id = root_material_id)[0].setName(name)
68
69    @pyqtSlot("QVariant")
70    def removeMaterial(self, material_node: "MaterialNode") -> None:
71        """Deletes a material from Cura.
72
73        This function does not do any safety checking any more. Please call this function only if:
74            - The material is not read-only.
75            - The material is not used in any stacks.
76
77        If the material was not lazy-loaded yet, this will fully load the container. When removing this material
78        node, all other materials with the same base fill will also be removed.
79
80        :param material_node: The material to remove.
81        """
82
83        container_registry = CuraContainerRegistry.getInstance()
84        materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file)
85
86        # The material containers belonging to the same material file are supposed to work together. This postponeSignals()
87        # does two things:
88        #   - optimizing the signal emitting.
89        #   - making sure that the signals will only be emitted after all the material containers have been removed.
90        with postponeSignals(container_registry.containerRemoved, compress = CompressTechnique.CompressPerParameterValue):
91            # CURA-6886: Some containers may not have been loaded. If remove one material container, its material file
92            # will be removed. If later we remove a sub-material container which hasn't been loaded previously, it will
93            # crash because removeContainer() requires to load the container first, but the material file was already
94            # gone.
95            for material_metadata in materials_this_base_file:
96                container_registry.findInstanceContainers(id = material_metadata["id"])
97            for material_metadata in materials_this_base_file:
98                container_registry.removeContainer(material_metadata["id"])
99
100    def duplicateMaterialByBaseFile(self, base_file: str, new_base_id: Optional[str] = None,
101                                    new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]:
102        """Creates a duplicate of a material with the same GUID and base_file metadata
103
104        :param base_file: The base file of the material to duplicate.
105        :param new_base_id: A new material ID for the base material. The IDs of the submaterials will be based off this
106        one. If not provided, a material ID will be generated automatically.
107        :param new_metadata: Metadata for the new material. If not provided, this will be duplicated from the original
108        material.
109
110        :return: The root material ID of the duplicate material.
111        """
112
113        container_registry = CuraContainerRegistry.getInstance()
114
115        root_materials = container_registry.findContainers(id = base_file)
116        if not root_materials:
117            Logger.log("i", "Unable to duplicate the root material with ID {root_id}, because it doesn't exist.".format(root_id = base_file))
118            return None
119        root_material = root_materials[0]
120
121        # Ensure that all settings are saved.
122        application = cura.CuraApplication.CuraApplication.getInstance()
123        application.saveSettings()
124
125        # Create a new ID and container to hold the data.
126        if new_base_id is None:
127            new_base_id = container_registry.uniqueName(root_material.getId())
128        new_root_material = copy.deepcopy(root_material)
129        new_root_material.getMetaData()["id"] = new_base_id
130        new_root_material.getMetaData()["base_file"] = new_base_id
131        if new_metadata is not None:
132            new_root_material.getMetaData().update(new_metadata)
133        new_containers = [new_root_material]
134
135        # Clone all submaterials.
136        for container_to_copy in container_registry.findInstanceContainers(base_file = base_file):
137            if container_to_copy.getId() == base_file:
138                continue  # We already have that one. Skip it.
139            new_id = new_base_id
140            definition = container_to_copy.getMetaDataEntry("definition")
141            if definition != "fdmprinter":
142                new_id += "_" + definition
143                variant_name = container_to_copy.getMetaDataEntry("variant_name")
144                if variant_name:
145                    new_id += "_" + variant_name.replace(" ", "_")
146
147            new_container = copy.deepcopy(container_to_copy)
148            new_container.getMetaData()["id"] = new_id
149            new_container.getMetaData()["base_file"] = new_base_id
150            if new_metadata is not None:
151                new_container.getMetaData().update(new_metadata)
152            new_containers.append(new_container)
153
154        # CURA-6863: Nodes in ContainerTree will be updated upon ContainerAdded signals, one at a time. It will use the
155        # best fit material container at the time it sees one. For example, if you duplicate and get generic_pva #2,
156        # if the node update function sees the containers in the following order:
157        #
158        #   - generic_pva #2
159        #   - generic_pva #2_um3_aa04
160        #
161        # It will first use "generic_pva #2" because that's the best fit it has ever seen, and later "generic_pva #2_um3_aa04"
162        # once it sees that. Because things run in the Qt event loop, they don't happen at the same time. This means if
163        # between those two events, the ContainerTree will have nodes that contain invalid data.
164        #
165        # This sort fixes the problem by emitting the most specific containers first.
166        new_containers = sorted(new_containers, key = lambda x: x.getId(), reverse = True)
167
168        # Optimization. Serving the same purpose as the postponeSignals() in removeMaterial()
169        # postpone the signals emitted when duplicating materials. This is easier on the event loop; changes the
170        # behavior to be like a transaction. Prevents concurrency issues.
171        with postponeSignals(container_registry.containerAdded, compress=CompressTechnique.CompressPerParameterValue):
172            for container_to_add in new_containers:
173                container_to_add.setDirty(True)
174                container_registry.addContainer(container_to_add)
175
176            # If the duplicated material was favorite then the new material should also be added to the favorites.
177            favorites_set = set(application.getPreferences().getValue("cura/favorite_materials").split(";"))
178            if base_file in favorites_set:
179                favorites_set.add(new_base_id)
180                application.getPreferences().setValue("cura/favorite_materials", ";".join(favorites_set))
181
182        return new_base_id
183
184    @pyqtSlot("QVariant", result = str)
185    def duplicateMaterial(self, material_node: "MaterialNode", new_base_id: Optional[str] = None,
186                          new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]:
187        """Creates a duplicate of a material with the same GUID and base_file metadata
188
189        :param material_node: The node representing the material to duplicate.
190        :param new_base_id: A new material ID for the base material. The IDs of the submaterials will be based off this
191        one. If not provided, a material ID will be generated automatically.
192        :param new_metadata: Metadata for the new material. If not provided, this will be duplicated from the original
193        material.
194
195        :return: The root material ID of the duplicate material.
196        """
197        return self.duplicateMaterialByBaseFile(material_node.base_file, new_base_id, new_metadata)
198
199    @pyqtSlot(result = str)
200    def createMaterial(self) -> str:
201        """Create a new material by cloning the preferred material for the current material diameter and generate a new
202        GUID.
203
204        The material type is explicitly left to be the one from the preferred material, since this allows the user to
205        still have SOME profiles to work with.
206
207        :return: The ID of the newly created material.
208        """
209
210        # Ensure all settings are saved.
211        application = cura.CuraApplication.CuraApplication.getInstance()
212        application.saveSettings()
213
214        # Find the preferred material.
215        extruder_stack = application.getMachineManager().activeStack
216        active_variant_name = extruder_stack.variant.getName()
217        approximate_diameter = int(extruder_stack.approximateMaterialDiameter)
218        global_container_stack = application.getGlobalContainerStack()
219        if not global_container_stack:
220            return ""
221        machine_node = ContainerTree.getInstance().machines[global_container_stack.definition.getId()]
222        preferred_material_node = machine_node.variants[active_variant_name].preferredMaterial(approximate_diameter)
223
224        # Create a new ID & new metadata for the new material.
225        new_id = CuraContainerRegistry.getInstance().uniqueName("custom_material")
226        new_metadata = {"name": catalog.i18nc("@label", "Custom Material"),
227                        "brand": catalog.i18nc("@label", "Custom"),
228                        "GUID": str(uuid.uuid4()),
229                        }
230
231        self.duplicateMaterial(preferred_material_node, new_base_id = new_id, new_metadata = new_metadata)
232        return new_id
233
234    @pyqtSlot(str)
235    def addFavorite(self, material_base_file: str) -> None:
236        """Adds a certain material to the favorite materials.
237
238        :param material_base_file: The base file of the material to add.
239        """
240
241        application = cura.CuraApplication.CuraApplication.getInstance()
242        favorites = application.getPreferences().getValue("cura/favorite_materials").split(";")
243        if material_base_file not in favorites:
244            favorites.append(material_base_file)
245            application.getPreferences().setValue("cura/favorite_materials", ";".join(favorites))
246            application.saveSettings()
247            self.favoritesChanged.emit(material_base_file)
248
249    @pyqtSlot(str)
250    def removeFavorite(self, material_base_file: str) -> None:
251        """Removes a certain material from the favorite materials.
252
253        If the material was not in the favorite materials, nothing happens.
254        """
255
256        application = cura.CuraApplication.CuraApplication.getInstance()
257        favorites = application.getPreferences().getValue("cura/favorite_materials").split(";")
258        try:
259            favorites.remove(material_base_file)
260            application.getPreferences().setValue("cura/favorite_materials", ";".join(favorites))
261            application.saveSettings()
262            self.favoritesChanged.emit(material_base_file)
263        except ValueError:  # Material was not in the favorites list.
264            Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file))
265