1# Copyright (c) 2019 Ultimaker B.V.
2# Cura is released under the terms of the LGPLv3 or higher.
3
4import copy
5import io
6import json #To parse the product-to-id mapping file.
7import os.path #To find the product-to-id mapping.
8from typing import Any, Dict, List, Optional, Tuple, cast, Set, Union
9import xml.etree.ElementTree as ET
10
11from UM.PluginRegistry import PluginRegistry
12from UM.Resources import Resources
13from UM.Logger import Logger
14import UM.Dictionary
15from UM.Settings.InstanceContainer import InstanceContainer
16from UM.Settings.ContainerRegistry import ContainerRegistry
17from UM.ConfigurationErrorMessage import ConfigurationErrorMessage
18
19from cura.CuraApplication import CuraApplication
20from cura.Machines.VariantType import VariantType
21
22try:
23    from .XmlMaterialValidator import XmlMaterialValidator
24except (ImportError, SystemError):
25    import XmlMaterialValidator  # type: ignore  # This fixes the tests not being able to import.
26
27
28class XmlMaterialProfile(InstanceContainer):
29    """Handles serializing and deserializing material containers from an XML file"""
30
31    CurrentFdmMaterialVersion = "1.3"
32    Version = 1
33
34    def __init__(self, container_id, *args, **kwargs):
35        super().__init__(container_id, *args, **kwargs)
36        self._inherited_files = []
37
38    @staticmethod
39    def xmlVersionToSettingVersion(xml_version: str) -> int:
40        """Translates the version number in the XML files to the setting_version metadata entry.
41
42        Since the two may increment independently we need a way to say which
43        versions of the XML specification are compatible with our setting data
44        version numbers.
45
46        :param xml_version: The version number found in an XML file.
47        :return: The corresponding setting_version.
48        """
49
50        if xml_version == "1.3":
51            return CuraApplication.SettingVersion
52        return 0  # Older than 1.3.
53
54    def getInheritedFiles(self):
55        return self._inherited_files
56
57    def setMetaDataEntry(self, key, value, apply_to_all = True):
58        """set the meta data for all machine / variant combinations
59
60        The "apply_to_all" flag indicates whether this piece of metadata should be applied to all material containers
61        or just this specific container.
62        For example, when you change the material name, you want to apply it to all its derived containers, but for
63        some specific settings, they should only be applied to a machine/variant-specific container.
64
65        Overridden from InstanceContainer
66        """
67
68        registry = ContainerRegistry.getInstance()
69        if registry.isReadOnly(self.getId()):
70            Logger.log("w", "Can't change metadata {key} of material {material_id} because it's read-only.".format(key = key, material_id = self.getId()))
71            return
72
73        # Some metadata such as diameter should also be instantiated to be a setting. Go though all values for the
74        # "properties" field and apply the new values to SettingInstances as well.
75        new_setting_values_dict = {}
76        if key == "properties":
77            for k, v in value.items():
78                if k in self.__material_properties_setting_map:
79                    new_setting_values_dict[self.__material_properties_setting_map[k]] = v
80
81        if not apply_to_all:  # Historical: If you only want to modify THIS container. We only used that to prevent recursion but with the below code that's no longer necessary.
82            # CURA-6920: This is an optimization, but it also fixes the problem that you can only set metadata for a
83            # material container that can be found in the container registry.
84            container_query = [self]
85        else:
86            container_query = registry.findContainers(base_file = self.getMetaDataEntry("base_file"))
87
88        for container in container_query:
89            if key not in container.getMetaData() or container.getMetaData()[key] != value:
90                container.getMetaData()[key] = value
91                container.setDirty(True)
92                container.metaDataChanged.emit(container)
93            for k, v in new_setting_values_dict.items():
94                self.setProperty(k, "value", v)
95
96    def setName(self, new_name):
97        """Overridden from InstanceContainer, similar to setMetaDataEntry.
98
99        without this function the setName would only set the name of the specific nozzle / material / machine combination container
100        The function is a bit tricky. It will not set the name of all containers if it has the correct name itself.
101        """
102
103        registry = ContainerRegistry.getInstance()
104        if registry.isReadOnly(self.getId()):
105            return
106
107        # Not only is this faster, it also prevents a major loop that causes a stack overflow.
108        if self.getName() == new_name:
109            return
110
111        super().setName(new_name)
112
113        basefile = self.getMetaDataEntry("base_file", self.getId())  # if basefile is self.getId, this is a basefile.
114        # Update the basefile as well, this is actually what we're trying to do
115        # Update all containers that share GUID and basefile
116        containers = registry.findInstanceContainers(base_file = basefile)
117        for container in containers:
118            container.setName(new_name)
119
120    def setDirty(self, dirty):
121        """Overridden from InstanceContainer, to set dirty to base file as well."""
122
123        super().setDirty(dirty)
124        base_file = self.getMetaDataEntry("base_file", None)
125        registry = ContainerRegistry.getInstance()
126        if base_file is not None and base_file != self.getId() and not registry.isReadOnly(base_file):
127            containers = registry.findContainers(id = base_file)
128            if containers:
129                containers[0].setDirty(dirty)
130
131    def serialize(self, ignored_metadata_keys: Optional[Set[str]] = None):
132        """Overridden from InstanceContainer
133
134        base file: common settings + supported machines
135        machine / variant combination: only changes for itself.
136        """
137        registry = ContainerRegistry.getInstance()
138
139        base_file = self.getMetaDataEntry("base_file", "")
140        if base_file and self.getId() != base_file:
141            # Since we create an instance of XmlMaterialProfile for each machine and nozzle in the profile,
142            # we should only serialize the "base" material definition, since that can then take care of
143            # serializing the machine/nozzle specific profiles.
144            raise NotImplementedError("Ignoring serializing non-root XML materials, the data is contained in the base material")
145
146        builder = ET.TreeBuilder()
147
148        root = builder.start("fdmmaterial",
149                             {"xmlns": "http://www.ultimaker.com/material",
150                              "xmlns:cura": "http://www.ultimaker.com/cura",
151                              "version": self.CurrentFdmMaterialVersion})
152
153        ## Begin Metadata Block
154        builder.start("metadata") # type: ignore
155
156        metadata = copy.deepcopy(self.getMetaData())
157        # setting_version is derived from the "version" tag in the schema, so don't serialize it into a file
158        if ignored_metadata_keys is None:
159            ignored_metadata_keys = set()
160        ignored_metadata_keys |= {"setting_version", "definition", "status", "variant", "type", "base_file", "approximate_diameter", "id", "container_type", "name", "compatible"}
161        # remove the keys that we want to ignore in the metadata
162        for key in ignored_metadata_keys:
163            if key in metadata:
164                del metadata[key]
165        properties = metadata.pop("properties", {})
166
167        ## Begin Name Block
168        builder.start("name") # type: ignore
169
170        builder.start("brand") # type: ignore
171        builder.data(metadata.pop("brand", ""))
172        builder.end("brand")
173
174        builder.start("material") # type: ignore
175        builder.data(metadata.pop("material", ""))
176        builder.end("material")
177
178        builder.start("color") # type: ignore
179        builder.data(metadata.pop("color_name", ""))
180        builder.end("color")
181
182        builder.start("label") # type: ignore
183        builder.data(self.getName())
184        builder.end("label")
185
186        builder.end("name")
187        ## End Name Block
188
189        for key, value in metadata.items():
190            key_to_use = key
191            if key in self._metadata_tags_that_have_cura_namespace:
192                key_to_use = "cura:" + key_to_use
193            builder.start(key_to_use) # type: ignore
194            if value is not None: #Nones get handled well by the builder.
195                #Otherwise the builder always expects a string.
196                #Deserialize expects the stringified version.
197                value = str(value)
198            builder.data(value)
199            builder.end(key_to_use)
200
201        builder.end("metadata")
202        ## End Metadata Block
203
204        ## Begin Properties Block
205        builder.start("properties") # type: ignore
206
207        for key, value in properties.items():
208            builder.start(key) # type: ignore
209            builder.data(value)
210            builder.end(key)
211
212        builder.end("properties")
213        ## End Properties Block
214
215        ## Begin Settings Block
216        builder.start("settings") # type: ignore
217
218        if self.getMetaDataEntry("definition") == "fdmprinter":
219            for instance in self.findInstances():
220                self._addSettingElement(builder, instance)
221
222        machine_container_map = {}  # type: Dict[str, InstanceContainer]
223        machine_variant_map = {}  # type: Dict[str, Dict[str, Any]]
224
225        root_material_id = self.getMetaDataEntry("base_file")  # if basefile is self.getId, this is a basefile.
226        all_containers = registry.findInstanceContainers(base_file = root_material_id)
227
228        for container in all_containers:
229            definition_id = container.getMetaDataEntry("definition")
230            if definition_id == "fdmprinter":
231                continue
232
233            if definition_id not in machine_container_map:
234                machine_container_map[definition_id] = container
235
236            if definition_id not in machine_variant_map:
237                machine_variant_map[definition_id] = {}
238
239            variant_name = container.getMetaDataEntry("variant_name")
240            if not variant_name:
241                machine_container_map[definition_id] = container
242                continue
243
244            variant_dict = {"variant_type": container.getMetaDataEntry("hardware_type", "nozzle"),
245                            "material_container": container}
246            machine_variant_map[definition_id][variant_name] = variant_dict
247
248        # Map machine human-readable names to IDs
249        product_id_map = self.getProductIdMap()
250
251        for definition_id, container in machine_container_map.items():
252            definition_id = container.getMetaDataEntry("definition")
253            definition_metadata = registry.findDefinitionContainersMetadata(id = definition_id)[0]
254
255            product = definition_id
256            for product_name, product_id_list in product_id_map.items():
257                if definition_id in product_id_list:
258                    product = product_name
259                    break
260
261            builder.start("machine") # type: ignore
262            builder.start("machine_identifier", {
263                "manufacturer": container.getMetaDataEntry("machine_manufacturer",
264                                                           definition_metadata.get("manufacturer", "Unknown")),
265                "product":  product
266            })
267            builder.end("machine_identifier")
268
269            for instance in container.findInstances():
270                if self.getMetaDataEntry("definition") == "fdmprinter" and self.getInstance(instance.definition.key) and self.getProperty(instance.definition.key, "value") == instance.value:
271                    # If the settings match that of the base profile, just skip since we inherit the base profile.
272                    continue
273
274                self._addSettingElement(builder, instance)
275
276            # Find all hotend sub-profiles corresponding to this material and machine and add them to this profile.
277            buildplate_dict = {} # type: Dict[str, Any]
278            for variant_name, variant_dict in machine_variant_map[definition_id].items():
279                variant_type = VariantType(variant_dict["variant_type"])
280                if variant_type == VariantType.NOZZLE:
281                    # The hotend identifier is not the containers name, but its "name".
282                    builder.start("hotend", {"id": variant_name})
283
284                    # Compatible is a special case, as it's added as a meta data entry (instead of an instance).
285                    material_container = variant_dict["material_container"]
286                    compatible = material_container.getMetaDataEntry("compatible")
287                    if compatible is not None:
288                        builder.start("setting", {"key": "hardware compatible"})
289                        if compatible:
290                            builder.data("yes")
291                        else:
292                            builder.data("no")
293                        builder.end("setting")
294
295                    for instance in material_container.findInstances():
296                        if container.getInstance(instance.definition.key) and container.getProperty(instance.definition.key, "value") == instance.value:
297                            # If the settings match that of the machine profile, just skip since we inherit the machine profile.
298                            continue
299
300                        self._addSettingElement(builder, instance)
301
302                    if material_container.getMetaDataEntry("buildplate_compatible") and not buildplate_dict:
303                        buildplate_dict["buildplate_compatible"] = material_container.getMetaDataEntry("buildplate_compatible")
304                        buildplate_dict["buildplate_recommended"] = material_container.getMetaDataEntry("buildplate_recommended")
305                        buildplate_dict["material_container"] = material_container
306
307                    builder.end("hotend")
308
309            if buildplate_dict:
310                for variant_name in buildplate_dict["buildplate_compatible"]:
311                    builder.start("buildplate", {"id": variant_name})
312
313                    material_container = buildplate_dict["material_container"]
314                    buildplate_compatible_dict = material_container.getMetaDataEntry("buildplate_compatible")
315                    buildplate_recommended_dict = material_container.getMetaDataEntry("buildplate_recommended")
316                    if buildplate_compatible_dict:
317                        compatible = buildplate_compatible_dict[variant_name]
318                        recommended = buildplate_recommended_dict[variant_name]
319
320                        builder.start("setting", {"key": "hardware compatible"})
321                        builder.data("yes" if compatible else "no")
322                        builder.end("setting")
323
324                        builder.start("setting", {"key": "hardware recommended"})
325                        builder.data("yes" if recommended else "no")
326                        builder.end("setting")
327
328                    builder.end("buildplate")
329
330            builder.end("machine")
331
332        builder.end("settings")
333        ## End Settings Block
334
335        builder.end("fdmmaterial")
336
337        root = builder.close()
338        _indent(root)
339        stream = io.BytesIO()
340        tree = ET.ElementTree(root)
341        # this makes sure that the XML header states encoding="utf-8"
342        tree.write(stream, encoding = "utf-8", xml_declaration = True)
343
344        return stream.getvalue().decode("utf-8")
345
346    # Recursively resolve loading inherited files
347    def _resolveInheritance(self, file_name):
348        xml = self._loadFile(file_name)
349
350        inherits = xml.find("./um:inherits", self.__namespaces)
351        if inherits is not None:
352            inherited = self._resolveInheritance(inherits.text)
353            xml = self._mergeXML(inherited, xml)
354
355        return xml
356
357    def _loadFile(self, file_name):
358        path = Resources.getPath(CuraApplication.getInstance().ResourceTypes.MaterialInstanceContainer, file_name + ".xml.fdm_material")
359
360        with open(path, encoding = "utf-8") as f:
361            contents = f.read()
362
363        self._inherited_files.append(path)
364        return ET.fromstring(contents)
365
366    # The XML material profile can have specific settings for machines.
367    # Some machines share profiles, so they are only created once.
368    # This function duplicates those elements so that each machine tag only has one identifier.
369    def _expandMachinesXML(self, element):
370        settings_element = element.find("./um:settings", self.__namespaces)
371        machines = settings_element.iterfind("./um:machine", self.__namespaces)
372        machines_to_add = []
373        machines_to_remove = []
374        for machine in machines:
375            identifiers = list(machine.iterfind("./um:machine_identifier", self.__namespaces))
376            has_multiple_identifiers = len(identifiers) > 1
377            if has_multiple_identifiers:
378                # Multiple identifiers found. We need to create a new machine element and copy all it's settings there.
379                for identifier in identifiers:
380                    new_machine = copy.deepcopy(machine)
381                    # Create list of identifiers that need to be removed from the copied element.
382                    other_identifiers = [self._createKey(other_identifier) for other_identifier in identifiers if other_identifier is not identifier]
383                    # As we can only remove by exact object reference, we need to look through the identifiers of copied machine.
384                    new_machine_identifiers = list(new_machine.iterfind("./um:machine_identifier", self.__namespaces))
385                    for new_machine_identifier in new_machine_identifiers:
386                        key = self._createKey(new_machine_identifier)
387                        # Key was in identifiers to remove, so this element needs to be purged
388                        if key in other_identifiers:
389                            new_machine.remove(new_machine_identifier)
390                    machines_to_add.append(new_machine)
391                machines_to_remove.append(machine)
392            else:
393                pass  # Machine only has one identifier. Nothing to do.
394        # Remove & add all required machines.
395        for machine_to_remove in machines_to_remove:
396            settings_element.remove(machine_to_remove)
397        for machine_to_add in machines_to_add:
398            settings_element.append(machine_to_add)
399        return element
400
401    def _mergeXML(self, first, second):
402        result = copy.deepcopy(first)
403        self._combineElement(self._expandMachinesXML(result), self._expandMachinesXML(second))
404        return result
405
406    @staticmethod
407    def _createKey(element):
408        key = element.tag.split("}")[-1]
409        if "key" in element.attrib:
410            key += " key:" + element.attrib["key"]
411        if "manufacturer" in element.attrib:
412            key += " manufacturer:" + element.attrib["manufacturer"]
413        if "product" in element.attrib:
414            key += " product:" + element.attrib["product"]
415        if key == "machine":
416            for item in element:
417                if "machine_identifier" in item.tag:
418                    key += " " + item.attrib["product"]
419        return key
420
421    # Recursively merges XML elements. Updates either the text or children if another element is found in first.
422    # If it does not exist, copies it from second.
423    @staticmethod
424    def _combineElement(first, second):
425        # Create a mapping from tag name to element.
426        mapping = {}
427        for element in first:
428            key = XmlMaterialProfile._createKey(element)
429            mapping[key] = element
430        for element in second:
431            key = XmlMaterialProfile._createKey(element)
432            if len(element):  # Check if element has children.
433                try:
434                    if "setting" in element.tag and not "settings" in element.tag:
435                        # Setting can have points in it. In that case, delete all values and override them.
436                        for child in list(mapping[key]):
437                            mapping[key].remove(child)
438                        for child in element:
439                            mapping[key].append(child)
440                    else:
441                        XmlMaterialProfile._combineElement(mapping[key], element)  # Multiple elements, handle those.
442                except KeyError:
443                    mapping[key] = element
444                    first.append(element)
445            else:
446                try:
447                    mapping[key].text = element.text
448                except KeyError:  # Not in the mapping, so simply add it
449                    mapping[key] = element
450                    first.append(element)
451
452    def clearData(self):
453        self._metadata = {
454            "id": self.getId(),
455            "name": ""
456        }
457        self._definition = None
458        self._instances = {}
459        self._read_only = False
460        self._dirty = False
461        self._path = ""
462
463    @classmethod
464    def getConfigurationTypeFromSerialized(cls, serialized: str) -> Optional[str]:
465        return "materials"
466
467    @classmethod
468    def getVersionFromSerialized(cls, serialized: str) -> int:
469        data = ET.fromstring(serialized)
470
471        version = XmlMaterialProfile.Version
472        # get setting version
473        if "version" in data.attrib:
474            setting_version = cls.xmlVersionToSettingVersion(data.attrib["version"])
475        else:
476            setting_version = cls.xmlVersionToSettingVersion("1.2")
477
478        return version * 1000000 + setting_version
479
480    def deserialize(self, serialized, file_name = None):
481        """Overridden from InstanceContainer"""
482
483        containers_to_add = []
484        # update the serialized data first
485        from UM.Settings.Interfaces import ContainerInterface
486        serialized = ContainerInterface.deserialize(self, serialized, file_name)
487
488        try:
489            data = ET.fromstring(serialized)
490        except:
491            Logger.logException("e", "An exception occurred while parsing the material profile")
492            return
493
494        # Reset previous metadata
495        old_id = self.getId()
496        self.clearData() # Ensure any previous data is gone.
497        meta_data = {}
498        meta_data["type"] = "material"
499        meta_data["base_file"] = self.getId()
500        meta_data["status"] = "unknown"  # TODO: Add material verification
501        meta_data["id"] = old_id
502        meta_data["container_type"] = XmlMaterialProfile
503
504        common_setting_values = {}
505
506        inherits = data.find("./um:inherits", self.__namespaces)
507        if inherits is not None:
508            inherited = self._resolveInheritance(inherits.text)
509            data = self._mergeXML(inherited, data)
510
511        # set setting_version in metadata
512        if "version" in data.attrib:
513            meta_data["setting_version"] = self.xmlVersionToSettingVersion(data.attrib["version"])
514        else:
515            meta_data["setting_version"] = self.xmlVersionToSettingVersion("1.2") #1.2 and lower didn't have that version number there yet.
516
517        meta_data["name"] = "Unknown Material" #In case the name tag is missing.
518        for entry in data.iterfind("./um:metadata/*", self.__namespaces):
519            tag_name = _tag_without_namespace(entry)
520
521            if tag_name == "name":
522                brand = entry.find("./um:brand", self.__namespaces)
523                material = entry.find("./um:material", self.__namespaces)
524                color = entry.find("./um:color", self.__namespaces)
525                label = entry.find("./um:label", self.__namespaces)
526
527                if label is not None and label.text is not None:
528                    meta_data["name"] = label.text
529                else:
530                    meta_data["name"] = self._profile_name(material.text, color.text)
531
532                meta_data["brand"] = brand.text if brand.text is not None else "Unknown Brand"
533                meta_data["material"] = material.text if material.text is not None else "Unknown Type"
534                meta_data["color_name"] = color.text if color.text is not None else "Unknown Color"
535                continue
536
537            # setting_version is derived from the "version" tag in the schema earlier, so don't set it here
538            if tag_name == "setting_version":
539                continue
540
541            meta_data[tag_name] = entry.text
542
543            if tag_name in self.__material_metadata_setting_map:
544                common_setting_values[self.__material_metadata_setting_map[tag_name]] = entry.text
545
546        if "description" not in meta_data:
547            meta_data["description"] = ""
548
549        if "adhesion_info" not in meta_data:
550            meta_data["adhesion_info"] = ""
551
552        validation_message = XmlMaterialValidator.validateMaterialMetaData(meta_data)
553        if validation_message is not None:
554            ConfigurationErrorMessage.getInstance().addFaultyContainers(self.getId())
555            Logger.log("e", "Not a valid material profile: {message}".format(message = validation_message))
556            return
557
558        property_values = {}
559        properties = data.iterfind("./um:properties/*", self.__namespaces)
560        for entry in properties:
561            tag_name = _tag_without_namespace(entry)
562            property_values[tag_name] = entry.text
563
564            if tag_name in self.__material_properties_setting_map:
565                common_setting_values[self.__material_properties_setting_map[tag_name]] = entry.text
566
567        meta_data["approximate_diameter"] = str(round(float(property_values.get("diameter", 2.85)))) # In mm
568        meta_data["properties"] = property_values
569        meta_data["definition"] = "fdmprinter"
570
571        common_compatibility = True
572        settings = data.iterfind("./um:settings/um:setting", self.__namespaces)
573        for entry in settings:
574            key = entry.get("key")
575            if key in self.__material_settings_setting_map:
576                if key == "processing temperature graph": #This setting has no setting text but subtags.
577                    graph_nodes = entry.iterfind("./um:point", self.__namespaces)
578                    graph_points = []
579                    for graph_node in graph_nodes:
580                        flow = float(graph_node.get("flow"))
581                        temperature = float(graph_node.get("temperature"))
582                        graph_points.append([flow, temperature])
583                    common_setting_values[self.__material_settings_setting_map[key]] = str(graph_points)
584                else:
585                    common_setting_values[self.__material_settings_setting_map[key]] = entry.text
586            elif key in self.__unmapped_settings:
587                if key == "hardware compatible":
588                    common_compatibility = self._parseCompatibleValue(entry.text)
589
590        # Add namespaced Cura-specific settings
591        settings = data.iterfind("./um:settings/cura:setting", self.__namespaces)
592        for entry in settings:
593            value = entry.text
594            if value.lower() == "yes":
595                value = True
596            elif value.lower() == "no":
597                value = False
598            key = entry.get("key")
599            common_setting_values[key] = value
600
601        self._cached_values = common_setting_values # from InstanceContainer ancestor
602
603        meta_data["compatible"] = common_compatibility
604        self.setMetaData(meta_data)
605        self._dirty = False
606
607        # Map machine human-readable names to IDs
608        product_id_map = self.getProductIdMap()
609
610        machines = data.iterfind("./um:settings/um:machine", self.__namespaces)
611        for machine in machines:
612            machine_compatibility = common_compatibility
613            machine_setting_values = {}
614            settings = machine.iterfind("./um:setting", self.__namespaces)
615            for entry in settings:
616                key = entry.get("key")
617                if key in self.__material_settings_setting_map:
618                    if key == "processing temperature graph": #This setting has no setting text but subtags.
619                        graph_nodes = entry.iterfind("./um:point", self.__namespaces)
620                        graph_points = []
621                        for graph_node in graph_nodes:
622                            flow = float(graph_node.get("flow"))
623                            temperature = float(graph_node.get("temperature"))
624                            graph_points.append([flow, temperature])
625                        machine_setting_values[self.__material_settings_setting_map[key]] = str(graph_points)
626                    else:
627                        machine_setting_values[self.__material_settings_setting_map[key]] = entry.text
628                elif key in self.__unmapped_settings:
629                    if key == "hardware compatible":
630                        machine_compatibility = self._parseCompatibleValue(entry.text)
631                else:
632                    Logger.log("d", "Unsupported material setting %s", key)
633
634            # Add namespaced Cura-specific settings
635            settings = machine.iterfind("./cura:setting", self.__namespaces)
636            for entry in settings:
637                value = entry.text
638                if value.lower() == "yes":
639                    value = True
640                elif value.lower() == "no":
641                    value = False
642                key = entry.get("key")
643                machine_setting_values[key] = value
644
645            cached_machine_setting_properties = common_setting_values.copy()
646            cached_machine_setting_properties.update(machine_setting_values)
647
648            identifiers = machine.iterfind("./um:machine_identifier", self.__namespaces)
649            for identifier in identifiers:
650                machine_id_list = product_id_map.get(identifier.get("product"), [])
651                if not machine_id_list:
652                    machine_id_list = self.getPossibleDefinitionIDsFromName(identifier.get("product"))
653
654                for machine_id in machine_id_list:
655                    definitions = ContainerRegistry.getInstance().findDefinitionContainersMetadata(id = machine_id)
656                    if not definitions:
657                        continue
658
659                    definition = definitions[0]
660
661                    machine_manufacturer = identifier.get("manufacturer", definition.get("manufacturer", "Unknown")) #If the XML material doesn't specify a manufacturer, use the one in the actual printer definition.
662
663                    # Always create the instance of the material even if it is not compatible, otherwise it will never
664                    # show as incompatible if the material profile doesn't define hotends in the machine - CURA-5444
665                    new_material_id = self.getId() + "_" + machine_id
666
667                    # The child or derived material container may already exist. This can happen when a material in a
668                    # project file and the a material in Cura have the same ID.
669                    # In the case if a derived material already exists, override that material container because if
670                    # the data in the parent material has been changed, the derived ones should be updated too.
671                    if ContainerRegistry.getInstance().isLoaded(new_material_id):
672                        new_material = ContainerRegistry.getInstance().findContainers(id = new_material_id)[0]
673                        is_new_material = False
674                    else:
675                        new_material = XmlMaterialProfile(new_material_id)
676                        is_new_material = True
677
678                    new_material.setMetaData(copy.deepcopy(self.getMetaData()))
679                    new_material.getMetaData()["id"] = new_material_id
680                    new_material.getMetaData()["name"] = self.getName()
681                    new_material.setDefinition(machine_id)
682                    # Don't use setMetadata, as that overrides it for all materials with same base file
683                    new_material.getMetaData()["compatible"] = machine_compatibility
684                    new_material.getMetaData()["machine_manufacturer"] = machine_manufacturer
685                    new_material.getMetaData()["definition"] = machine_id
686
687                    new_material.setCachedValues(cached_machine_setting_properties)
688
689                    new_material._dirty = False
690
691                    if is_new_material:
692                        containers_to_add.append(new_material)
693
694                    hotends = machine.iterfind("./um:hotend", self.__namespaces)
695                    for hotend in hotends:
696                        # The "id" field for hotends in material profiles is actually name
697                        hotend_name = hotend.get("id")
698                        if hotend_name is None:
699                            continue
700
701                        hotend_mapped_settings, hotend_unmapped_settings = self._getSettingsDictForNode(hotend)
702                        hotend_compatibility = hotend_unmapped_settings.get("hardware compatible", machine_compatibility)
703
704                        # Generate container ID for the hotend-specific material container
705                        new_hotend_specific_material_id = self.getId() + "_" + machine_id + "_" + hotend_name.replace(" ", "_")
706
707                        # Same as machine compatibility, keep the derived material containers consistent with the parent material
708                        if ContainerRegistry.getInstance().isLoaded(new_hotend_specific_material_id):
709                            new_hotend_material = ContainerRegistry.getInstance().findContainers(id = new_hotend_specific_material_id)[0]
710                            is_new_material = False
711                        else:
712                            new_hotend_material = XmlMaterialProfile(new_hotend_specific_material_id)
713                            is_new_material = True
714
715                        new_hotend_material.setMetaData(copy.deepcopy(self.getMetaData()))
716                        new_hotend_material.getMetaData()["id"] = new_hotend_specific_material_id
717                        new_hotend_material.getMetaData()["name"] = self.getName()
718                        new_hotend_material.getMetaData()["variant_name"] = hotend_name
719                        new_hotend_material.setDefinition(machine_id)
720                        # Don't use setMetadata, as that overrides it for all materials with same base file
721                        new_hotend_material.getMetaData()["compatible"] = hotend_compatibility
722                        new_hotend_material.getMetaData()["machine_manufacturer"] = machine_manufacturer
723                        new_hotend_material.getMetaData()["definition"] = machine_id
724
725                        cached_hotend_setting_properties = cached_machine_setting_properties.copy()
726                        cached_hotend_setting_properties.update(hotend_mapped_settings)
727
728                        new_hotend_material.setCachedValues(cached_hotend_setting_properties)
729
730                        new_hotend_material._dirty = False
731
732                        if is_new_material:
733                            if ContainerRegistry.getInstance().isReadOnly(self.getId()):
734                                ContainerRegistry.getInstance().setExplicitReadOnly(new_hotend_material.getId())
735                            containers_to_add.append(new_hotend_material)
736
737                    # there is only one ID for a machine. Once we have reached here, it means we have already found
738                    # a workable ID for that machine, so there is no need to continue
739                    break
740
741        for container_to_add in containers_to_add:
742            ContainerRegistry.getInstance().addContainer(container_to_add)
743
744    @classmethod
745    def _getSettingsDictForNode(cls, node) -> Tuple[Dict[str,  Any], Dict[str, Any]]:
746        node_mapped_settings_dict = dict()  # type: Dict[str, Any]
747        node_unmapped_settings_dict = dict()  # type: Dict[str, Any]
748
749        # Fetch settings in the "um" namespace
750        um_settings = node.iterfind("./um:setting", cls.__namespaces)
751        for um_setting_entry in um_settings:
752            setting_key = um_setting_entry.get("key")
753
754            # Mapped settings
755            if setting_key in cls.__material_settings_setting_map:
756                if setting_key == "processing temperature graph":  # This setting has no setting text but subtags.
757                    graph_nodes = um_setting_entry.iterfind("./um:point", cls.__namespaces)
758                    graph_points = []
759                    for graph_node in graph_nodes:
760                        flow = float(graph_node.get("flow"))
761                        temperature = float(graph_node.get("temperature"))
762                        graph_points.append([flow, temperature])
763                    node_mapped_settings_dict[cls.__material_settings_setting_map[setting_key]] = str(
764                        graph_points)
765                else:
766                    node_mapped_settings_dict[cls.__material_settings_setting_map[setting_key]] = um_setting_entry.text
767
768            # Unmapped settings
769            elif setting_key in cls.__unmapped_settings:
770                if setting_key in ("hardware compatible", "hardware recommended"):
771                    node_unmapped_settings_dict[setting_key] = cls._parseCompatibleValue(um_setting_entry.text)
772
773            # Unknown settings
774            else:
775                Logger.log("w", "Unsupported material setting %s", setting_key)
776
777        # Fetch settings in the "cura" namespace
778        cura_settings = node.iterfind("./cura:setting", cls.__namespaces)
779        for cura_setting_entry in cura_settings:
780            value = cura_setting_entry.text
781            if value.lower() == "yes":
782                value = True
783            elif value.lower() == "no":
784                value = False
785            key = cura_setting_entry.get("key")
786
787            # Cura settings are all mapped
788            node_mapped_settings_dict[key] = value
789
790        return node_mapped_settings_dict, node_unmapped_settings_dict
791
792    @classmethod
793    def deserializeMetadata(cls, serialized: str, container_id: str) -> List[Dict[str, Any]]:
794        result_metadata = [] #All the metadata that we found except the base (because the base is returned).
795
796        #Update the serialized data to the latest version.
797        serialized = cls._updateSerialized(serialized)
798
799        base_metadata = {
800            "type": "material",
801            "status": "unknown", #TODO: Add material verification.
802            "container_type": XmlMaterialProfile,
803            "id": container_id,
804            "base_file": container_id
805        }
806
807        try:
808            data = ET.fromstring(serialized)
809        except:
810            Logger.logException("e", "An exception occurred while parsing the material profile")
811            return []
812
813        #TODO: Implement the <inherits> tag. It's unused at the moment though.
814
815        if "version" in data.attrib:
816            base_metadata["setting_version"] = cls.xmlVersionToSettingVersion(data.attrib["version"])
817        else:
818            base_metadata["setting_version"] = cls.xmlVersionToSettingVersion("1.2") #1.2 and lower didn't have that version number there yet.
819
820        for entry in data.iterfind("./um:metadata/*", cls.__namespaces):
821            tag_name = _tag_without_namespace(entry)
822
823            if tag_name == "name":
824                brand = entry.find("./um:brand", cls.__namespaces)
825                material = entry.find("./um:material", cls.__namespaces)
826                color = entry.find("./um:color", cls.__namespaces)
827                label = entry.find("./um:label", cls.__namespaces)
828
829                if label is not None and label.text is not None:
830                    base_metadata["name"] = label.text
831                else:
832                    if material is not None and color is not None:
833                        base_metadata["name"] = cls._profile_name(material.text, color.text)
834                    else:
835                        base_metadata["name"] = "Unknown Material"
836
837                base_metadata["brand"] = brand.text if brand is not None and brand.text is not None else "Unknown Brand"
838                base_metadata["material"] = material.text if material is not None and material.text is not None else "Unknown Type"
839                base_metadata["color_name"] = color.text if color is not None and color.text is not None else "Unknown Color"
840                continue
841
842            #Setting_version is derived from the "version" tag in the schema earlier, so don't set it here.
843            if tag_name == "setting_version":
844                continue
845
846            base_metadata[tag_name] = entry.text
847
848        if "description" not in base_metadata:
849            base_metadata["description"] = ""
850        if "adhesion_info" not in base_metadata:
851            base_metadata["adhesion_info"] = ""
852
853        property_values = {}
854        properties = data.iterfind("./um:properties/*", cls.__namespaces)
855        for entry in properties:
856            tag_name = _tag_without_namespace(entry)
857            property_values[tag_name] = entry.text
858
859        base_metadata["approximate_diameter"] = str(round(float(cast(float, property_values.get("diameter", 2.85))))) # In mm
860        base_metadata["properties"] = property_values
861        base_metadata["definition"] = "fdmprinter"
862
863        compatible_entries = data.iterfind("./um:settings/um:setting[@key='hardware compatible']", cls.__namespaces)
864        try:
865            common_compatibility = cls._parseCompatibleValue(next(compatible_entries).text) # type: ignore
866        except StopIteration: #No 'hardware compatible' setting.
867            common_compatibility = True
868        base_metadata["compatible"] = common_compatibility
869        result_metadata.append(base_metadata)
870
871        # Map machine human-readable names to IDs
872        product_id_map = cls.getProductIdMap()
873
874        for machine in data.iterfind("./um:settings/um:machine", cls.__namespaces):
875            machine_compatibility = common_compatibility
876            for entry in machine.iterfind("./um:setting[@key='hardware compatible']", cls.__namespaces):
877                if entry.text is not None:
878                    machine_compatibility = cls._parseCompatibleValue(entry.text)
879
880            for identifier in machine.iterfind("./um:machine_identifier", cls.__namespaces):
881                machine_id_list = product_id_map.get(identifier.get("product", ""), [])
882                if not machine_id_list:
883                    machine_id_list = cls.getPossibleDefinitionIDsFromName(identifier.get("product"))
884
885                for machine_id in machine_id_list:
886                    definition_metadatas = ContainerRegistry.getInstance().findDefinitionContainersMetadata(id = machine_id)
887                    if not definition_metadatas:
888                        continue
889
890                    definition_metadata = definition_metadatas[0]
891
892                    machine_manufacturer = identifier.get("manufacturer", definition_metadata.get("manufacturer", "Unknown")) #If the XML material doesn't specify a manufacturer, use the one in the actual printer definition.
893
894                    # Always create the instance of the material even if it is not compatible, otherwise it will never
895                    # show as incompatible if the material profile doesn't define hotends in the machine - CURA-5444
896                    new_material_id = container_id + "_" + machine_id
897
898                    # Do not look for existing container/container metadata with the same ID although they may exist.
899                    # In project loading and perhaps some other places, we only want to get information (metadata)
900                    # from a file without changing the current state of the system. If we overwrite the existing
901                    # metadata here, deserializeMetadata() will not be safe for retrieving information.
902                    new_material_metadata = {}
903
904                    new_material_metadata.update(base_metadata)
905                    new_material_metadata["id"] = new_material_id
906                    new_material_metadata["compatible"] = machine_compatibility
907                    new_material_metadata["machine_manufacturer"] = machine_manufacturer
908                    new_material_metadata["definition"] = machine_id
909
910                    result_metadata.append(new_material_metadata)
911
912                    buildplates = machine.iterfind("./um:buildplate", cls.__namespaces)
913                    buildplate_map = {}  # type: Dict[str, Dict[str, bool]]
914                    buildplate_map["buildplate_compatible"] = {}
915                    buildplate_map["buildplate_recommended"] = {}
916                    for buildplate in buildplates:
917                        buildplate_id = buildplate.get("id")
918                        if buildplate_id is None:
919                            continue
920
921                        variant_metadata = ContainerRegistry.getInstance().findInstanceContainersMetadata(id = buildplate_id)
922                        if not variant_metadata:
923                            # It is not really properly defined what "ID" is so also search for variants by name.
924                            variant_metadata = ContainerRegistry.getInstance().findInstanceContainersMetadata(definition = machine_id, name = buildplate_id)
925
926                        if not variant_metadata:
927                            continue
928
929                        settings = buildplate.iterfind("./um:setting", cls.__namespaces)
930                        buildplate_compatibility = True
931                        buildplate_recommended = True
932                        for entry in settings:
933                            key = entry.get("key")
934                            if entry.text is not None:
935                                if key == "hardware compatible":
936                                    buildplate_compatibility = cls._parseCompatibleValue(entry.text)
937                                elif key == "hardware recommended":
938                                    buildplate_recommended = cls._parseCompatibleValue(entry.text)
939
940                        buildplate_map["buildplate_compatible"][buildplate_id] = buildplate_compatibility
941                        buildplate_map["buildplate_recommended"][buildplate_id] = buildplate_recommended
942
943                    for hotend in machine.iterfind("./um:hotend", cls.__namespaces):
944                        hotend_name = hotend.get("id")
945                        if hotend_name is None:
946                            continue
947
948                        hotend_compatibility = machine_compatibility
949                        for entry in hotend.iterfind("./um:setting[@key='hardware compatible']", cls.__namespaces):
950                            if entry.text is not None:
951                                hotend_compatibility = cls._parseCompatibleValue(entry.text)
952
953                        new_hotend_specific_material_id = container_id + "_" + machine_id + "_" + hotend_name.replace(" ", "_")
954
955                        # Same as above, do not overwrite existing metadata.
956                        new_hotend_material_metadata = {}
957
958                        new_hotend_material_metadata.update(base_metadata)
959                        new_hotend_material_metadata["variant_name"] = hotend_name
960                        new_hotend_material_metadata["compatible"] = hotend_compatibility
961                        new_hotend_material_metadata["machine_manufacturer"] = machine_manufacturer
962                        new_hotend_material_metadata["id"] = new_hotend_specific_material_id
963                        new_hotend_material_metadata["definition"] = machine_id
964                        if buildplate_map["buildplate_compatible"]:
965                            new_hotend_material_metadata["buildplate_compatible"] = buildplate_map["buildplate_compatible"]
966                            new_hotend_material_metadata["buildplate_recommended"] = buildplate_map["buildplate_recommended"]
967
968                        result_metadata.append(new_hotend_material_metadata)
969
970                        #
971                        # Buildplates in Hotends
972                        #
973                        buildplates = hotend.iterfind("./um:buildplate", cls.__namespaces)
974                        for buildplate in buildplates:
975                            # The "id" field for buildplate in material profiles is actually name
976                            buildplate_name = buildplate.get("id")
977                            if buildplate_name is None:
978                                continue
979
980                            buildplate_mapped_settings, buildplate_unmapped_settings = cls._getSettingsDictForNode(buildplate)
981                            buildplate_compatibility = buildplate_unmapped_settings.get("hardware compatible",
982                                                                                        buildplate_map["buildplate_compatible"])
983                            buildplate_recommended = buildplate_unmapped_settings.get("hardware recommended",
984                                                                                      buildplate_map["buildplate_recommended"])
985
986                            # Generate container ID for the hotend-and-buildplate-specific material container
987                            new_hotend_and_buildplate_specific_material_id = new_hotend_specific_material_id + "_" + buildplate_name.replace(
988                                " ", "_")
989
990                            new_hotend_and_buildplate_material_metadata = {}
991                            new_hotend_and_buildplate_material_metadata.update(new_hotend_material_metadata)
992                            new_hotend_and_buildplate_material_metadata["id"] = new_hotend_and_buildplate_specific_material_id
993                            new_hotend_and_buildplate_material_metadata["buildplate_name"] = buildplate_name
994                            new_hotend_and_buildplate_material_metadata["compatible"] = buildplate_compatibility
995                            new_hotend_and_buildplate_material_metadata["buildplate_compatible"] = buildplate_compatibility
996                            new_hotend_and_buildplate_material_metadata["buildplate_recommended"] = buildplate_recommended
997
998                            result_metadata.append(new_hotend_and_buildplate_material_metadata)
999
1000                    # there is only one ID for a machine. Once we have reached here, it means we have already found
1001                    # a workable ID for that machine, so there is no need to continue
1002                    break
1003
1004        return result_metadata
1005
1006    def _addSettingElement(self, builder, instance):
1007        key = instance.definition.key
1008        if key in self.__material_settings_setting_map.values():
1009            # Setting has a key in the standard namespace
1010            key = UM.Dictionary.findKey(self.__material_settings_setting_map, instance.definition.key)
1011            tag_name = "setting"
1012
1013            if key == "processing temperature graph": #The Processing Temperature Graph has its own little structure that we need to implement separately.
1014                builder.start(tag_name, {"key": key})
1015                graph_str = str(instance.value)
1016                graph = graph_str.replace("[", "").replace("]", "").split(", ") #Crude parsing of this list: Flatten the list by removing all brackets, then split on ", ". Safe to eval attacks though!
1017                graph = [graph[i:i + 2] for i in range(0, len(graph) - 1, 2)] #Convert to 2D array.
1018                for point in graph:
1019                    builder.start("point", {"flow": point[0], "temperature": point[1]})
1020                    builder.end("point")
1021                builder.end(tag_name)
1022                return
1023
1024        elif key not in self.__material_properties_setting_map.values() and key not in self.__material_metadata_setting_map.values():
1025            # Setting is not in the standard namespace, and not a material property (eg diameter) or metadata (eg GUID)
1026            tag_name = "cura:setting"
1027        else:
1028            # Skip material properties (eg diameter) or metadata (eg GUID)
1029            return
1030
1031        if instance.value is True:
1032            data = "yes"
1033        elif instance.value is False:
1034            data = "no"
1035        else:
1036            data = str(instance.value)
1037
1038        builder.start(tag_name, { "key": key })
1039        builder.data(data)
1040        builder.end(tag_name)
1041
1042    @staticmethod
1043    def _profile_name(material_name, color_name):
1044        if material_name is None:
1045            return "Unknown Material"
1046        if color_name != "Generic":
1047            return "%s %s" % (color_name, material_name)
1048        else:
1049            return material_name
1050
1051    @staticmethod
1052    def getPossibleDefinitionIDsFromName(name):
1053        name_parts = name.lower().split(" ")
1054        merged_name_parts = []
1055        for part in name_parts:
1056            if len(part) == 0:
1057                continue
1058            if len(merged_name_parts) == 0:
1059                merged_name_parts.append(part)
1060                continue
1061            if part.isdigit():
1062                # for names with digit(s) such as Ultimaker 3 Extended, we generate an ID like
1063                # "ultimaker3_extended", ignoring the space between "Ultimaker" and "3".
1064                merged_name_parts[-1] = merged_name_parts[-1] + part
1065            else:
1066                merged_name_parts.append(part)
1067
1068        id_list = {name.lower().replace(" ", ""),  # simply removing all spaces
1069                   name.lower().replace(" ", "_"),  # simply replacing all spaces with underscores
1070                   "_".join(merged_name_parts),
1071                   }
1072        id_list = list(id_list)
1073        return id_list
1074
1075    @classmethod
1076    def getProductIdMap(cls) -> Dict[str, List[str]]:
1077        """Gets a mapping from product names in the XML files to their definition IDs.
1078
1079        This loads the mapping from a file.
1080        """
1081
1082        plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath("XmlMaterialProfile"))
1083        product_to_id_file = os.path.join(plugin_path, "product_to_id.json")
1084        with open(product_to_id_file, encoding = "utf-8") as f:
1085            product_to_id_map = json.load(f)
1086        product_to_id_map = {key: [value] for key, value in product_to_id_map.items()}
1087        #This also loads "Ultimaker S5" -> "ultimaker_s5" even though that is not strictly necessary with the default to change spaces into underscores.
1088        #However it is not always loaded with that default; this mapping is also used in serialize() without that default.
1089        return product_to_id_map
1090
1091    @staticmethod
1092    def _parseCompatibleValue(value: str):
1093        """Parse the value of the "material compatible" property."""
1094
1095        return value in {"yes", "unknown"}
1096
1097    def __str__(self):
1098        """Small string representation for debugging."""
1099
1100        return "<XmlMaterialProfile '{my_id}' ('{name}') from base file '{base_file}'>".format(my_id = self.getId(), name = self.getName(), base_file = self.getMetaDataEntry("base_file"))
1101
1102    _metadata_tags_that_have_cura_namespace = {"pva_compatible", "breakaway_compatible"}
1103
1104    # Map XML file setting names to internal names
1105    __material_settings_setting_map = {
1106        "print temperature": "default_material_print_temperature",
1107        "heated bed temperature": "default_material_bed_temperature",
1108        "standby temperature": "material_standby_temperature",
1109        "processing temperature graph": "material_flow_temp_graph",
1110        "print cooling": "cool_fan_speed",
1111        "retraction amount": "retraction_amount",
1112        "retraction speed": "retraction_speed",
1113        "adhesion tendency": "material_adhesion_tendency",
1114        "surface energy": "material_surface_energy",
1115        "build volume temperature": "build_volume_temperature",
1116        "anti ooze retract position": "material_anti_ooze_retracted_position",
1117        "anti ooze retract speed": "material_anti_ooze_retraction_speed",
1118        "break preparation position": "material_break_preparation_retracted_position",
1119        "break preparation speed": "material_break_preparation_speed",
1120        "break preparation temperature": "material_break_preparation_temperature",
1121        "break position": "material_break_retracted_position",
1122        "flush purge speed": "material_flush_purge_speed",
1123        "flush purge length": "material_flush_purge_length",
1124        "end of filament purge speed": "material_end_of_filament_purge_speed",
1125        "end of filament purge length": "material_end_of_filament_purge_length",
1126        "maximum park duration": "material_maximum_park_duration",
1127        "no load move factor": "material_no_load_move_factor",
1128        "break speed": "material_break_speed",
1129        "break temperature": "material_break_temperature"
1130    }  # type: Dict[str, str]
1131    __unmapped_settings = [
1132        "hardware compatible",
1133        "hardware recommended"
1134    ]
1135    __material_properties_setting_map = {
1136        "diameter": "material_diameter"
1137    }
1138    __material_metadata_setting_map = {
1139        "GUID": "material_guid"
1140    }
1141
1142    # Map of recognised namespaces with a proper prefix.
1143    __namespaces = {
1144        "um": "http://www.ultimaker.com/material",
1145        "cura": "http://www.ultimaker.com/cura"
1146    }
1147
1148
1149def _indent(elem, level = 0):
1150    """Helper function for pretty-printing XML because ETree is stupid"""
1151
1152    i = "\n" + level * "  "
1153    if len(elem):
1154        if not elem.text or not elem.text.strip():
1155            elem.text = i + "  "
1156        if not elem.tail or not elem.tail.strip():
1157            elem.tail = i
1158        for elem in elem:
1159            _indent(elem, level + 1)
1160        if not elem.tail or not elem.tail.strip():
1161            elem.tail = i
1162    else:
1163        if level and (not elem.tail or not elem.tail.strip()):
1164            elem.tail = i
1165
1166
1167# The namespace is prepended to the tag name but between {}.
1168# We are only interested in the actual tag name, so discard everything
1169# before the last }
1170def _tag_without_namespace(element):
1171    return element.tag[element.tag.rfind("}") + 1:]
1172