1# Copyright (c) 2019 Ultimaker B.V.
2# Cura is released under the terms of the LGPLv3 or higher.
3from typing import TYPE_CHECKING
4
5from UM.Logger import Logger
6from UM.Settings.ContainerRegistry import ContainerRegistry
7from UM.Settings.Interfaces import ContainerInterface
8from UM.Signal import Signal
9
10from cura.Machines.ContainerNode import ContainerNode
11from cura.Machines.MaterialNode import MaterialNode
12
13import UM.FlameProfiler
14
15if TYPE_CHECKING:
16    from typing import Dict
17    from cura.Machines.MachineNode import MachineNode
18
19
20class VariantNode(ContainerNode):
21    """This class represents an extruder variant in the container tree.
22
23    The subnodes of these nodes are materials.
24
25    This node contains materials with ALL filament diameters underneath it. The tree of this variant is not specific
26    to one global stack, so because the list of materials can be different per stack depending on the compatible
27    material diameter setting, we cannot filter them here. Filtering must be done in the model.
28    """
29
30    def __init__(self, container_id: str, machine: "MachineNode") -> None:
31        super().__init__(container_id)
32        self.machine = machine
33        self.materials = {}  # type: Dict[str, MaterialNode]  # Mapping material base files to their nodes.
34        self.materialsChanged = Signal()
35
36        container_registry = ContainerRegistry.getInstance()
37        self.variant_name = container_registry.findContainersMetadata(id = container_id)[0]["name"]  # Store our own name so that we can filter more easily.
38        container_registry.containerAdded.connect(self._materialAdded)
39        container_registry.containerRemoved.connect(self._materialRemoved)
40        self._loadAll()
41
42    @UM.FlameProfiler.profile
43    def _loadAll(self) -> None:
44        """(Re)loads all materials under this variant."""
45
46        container_registry = ContainerRegistry.getInstance()
47
48        if not self.machine.has_materials:
49            self.materials["empty_material"] = MaterialNode("empty_material", variant = self)
50            return  # There should not be any materials loaded for this printer.
51
52        # Find all the materials for this variant's name.
53        else:  # Printer has its own material profiles. Look for material profiles with this printer's definition.
54            base_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = "fdmprinter")
55            printer_specific_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = self.machine.container_id)
56            variant_specific_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = self.machine.container_id, variant_name = self.variant_name)  # If empty_variant, this won't return anything.
57            materials_per_base_file = {material["base_file"]: material for material in base_materials}
58            materials_per_base_file.update({material["base_file"]: material for material in printer_specific_materials})  # Printer-specific profiles override global ones.
59            materials_per_base_file.update({material["base_file"]: material for material in variant_specific_materials})  # Variant-specific profiles override all of those.
60            materials = list(materials_per_base_file.values())
61
62        # Filter materials based on the exclude_materials property.
63        filtered_materials = [material for material in materials if material["id"] not in self.machine.exclude_materials]
64
65        for material in filtered_materials:
66            base_file = material["base_file"]
67            if base_file not in self.materials:
68                self.materials[base_file] = MaterialNode(material["id"], variant = self)
69                self.materials[base_file].materialChanged.connect(self.materialsChanged)
70        if not self.materials:
71            self.materials["empty_material"] = MaterialNode("empty_material", variant = self)
72
73    def preferredMaterial(self, approximate_diameter: int) -> MaterialNode:
74        """Finds the preferred material for this printer with this nozzle in one of the extruders.
75
76        If the preferred material is not available, an arbitrary material is returned. If there is a configuration
77        mistake (like a typo in the preferred material) this returns a random available material. If there are no
78        available materials, this will return the empty material node.
79
80        :param approximate_diameter: The desired approximate diameter of the material.
81
82        :return: The node for the preferred material, or any arbitrary material if there is no match.
83        """
84
85        for base_material, material_node in self.materials.items():
86            if self.machine.preferred_material == base_material and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")):
87                return material_node
88
89        # First fallback: Check if we should be checking for the 175 variant.
90        if approximate_diameter == 2:
91            preferred_material = self.machine.preferred_material + "_175"
92            for base_material, material_node in self.materials.items():
93                if preferred_material == base_material and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")):
94                    return material_node
95
96        # Second fallback: Choose any material with matching diameter.
97        for material_node in self.materials.values():
98            if material_node.getMetaDataEntry("approximate_diameter") and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")):
99                Logger.log("w", "Could not find preferred material %s, falling back to whatever works", self.machine.preferred_material)
100                return material_node
101
102        fallback = next(iter(self.materials.values()))  # Should only happen with empty material node.
103        Logger.log("w", "Could not find preferred material {preferred_material} with diameter {diameter} for variant {variant_id}, falling back to {fallback}.".format(
104            preferred_material = self.machine.preferred_material,
105            diameter = approximate_diameter,
106            variant_id = self.container_id,
107            fallback = fallback.container_id
108        ))
109        return fallback
110
111    @UM.FlameProfiler.profile
112    def _materialAdded(self, container: ContainerInterface) -> None:
113        """When a material gets added to the set of profiles, we need to update our tree here."""
114
115        if container.getMetaDataEntry("type") != "material":
116            return  # Not interested.
117        if not ContainerRegistry.getInstance().findContainersMetadata(id = container.getId()):
118            # CURA-6889
119            # containerAdded and removed signals may be triggered in the next event cycle. If a container gets added
120            # and removed in the same event cycle, in the next cycle, the connections should just ignore the signals.
121            # The check here makes sure that the container in the signal still exists.
122            Logger.log("d", "Got container added signal for container [%s] but it no longer exists, do nothing.",
123                       container.getId())
124            return
125        if not self.machine.has_materials:
126            return  # We won't add any materials.
127        material_definition = container.getMetaDataEntry("definition")
128
129        base_file = container.getMetaDataEntry("base_file")
130        if base_file in self.machine.exclude_materials:
131            return  # Material is forbidden for this printer.
132        if base_file not in self.materials:  # Completely new base file. Always better than not having a file as long as it matches our set-up.
133            if material_definition != "fdmprinter" and material_definition != self.machine.container_id:
134                return
135            material_variant = container.getMetaDataEntry("variant_name")
136            if material_variant is not None and material_variant != self.variant_name:
137                return
138        else:  # We already have this base profile. Replace the base profile if the new one is more specific.
139            new_definition = container.getMetaDataEntry("definition")
140            if new_definition == "fdmprinter":
141                return  # Just as unspecific or worse.
142            material_variant = container.getMetaDataEntry("variant_name")
143            if new_definition != self.machine.container_id or material_variant != self.variant_name:
144                return  # Doesn't match this set-up.
145            original_metadata = ContainerRegistry.getInstance().findContainersMetadata(id = self.materials[base_file].container_id)[0]
146            if "variant_name" in original_metadata or material_variant is None:
147                return  # Original was already specific or just as unspecific as the new one.
148
149        if "empty_material" in self.materials:
150            del self.materials["empty_material"]
151        self.materials[base_file] = MaterialNode(container.getId(), variant = self)
152        self.materials[base_file].materialChanged.connect(self.materialsChanged)
153        self.materialsChanged.emit(self.materials[base_file])
154
155    @UM.FlameProfiler.profile
156    def _materialRemoved(self, container: ContainerInterface) -> None:
157        if container.getMetaDataEntry("type") != "material":
158            return  # Only interested in materials.
159        base_file = container.getMetaDataEntry("base_file")
160        if base_file not in self.materials:
161            return  # We don't track this material anyway. No need to remove it.
162
163        original_node = self.materials[base_file]
164        del self.materials[base_file]
165        self.materialsChanged.emit(original_node)
166
167        # Now a different material from the same base file may have been hidden because it was not as specific as the one we deleted.
168        # Search for any submaterials from that base file that are still left.
169        materials_same_base_file = ContainerRegistry.getInstance().findContainersMetadata(base_file = base_file)
170        if materials_same_base_file:
171            most_specific_submaterial = None
172            for submaterial in materials_same_base_file:
173                if submaterial["definition"] == self.machine.container_id:
174                    if submaterial.get("variant_name", "empty") == self.variant_name:
175                        most_specific_submaterial = submaterial
176                        break  # most specific match possible
177                    if submaterial.get("variant_name", "empty") == "empty":
178                        most_specific_submaterial = submaterial
179
180            if most_specific_submaterial is None:
181                Logger.log("w", "Material %s removed, but no suitable replacement found", base_file)
182            else:
183                Logger.log("i", "Material %s (%s) overridden by %s", base_file, self.variant_name, most_specific_submaterial.get("id"))
184                self.materials[base_file] = MaterialNode(most_specific_submaterial["id"], variant = self)
185                self.materialsChanged.emit(self.materials[base_file])
186
187        if not self.materials:  # The last available material just got deleted and there is nothing with the same base file to replace it.
188            self.materials["empty_material"] = MaterialNode("empty_material", variant = self)
189            self.materialsChanged.emit(self.materials["empty_material"])