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