1# Copyright (c) 2019 Ultimaker B.V. 2# Cura is released under the terms of the LGPLv3 or higher. 3 4import os 5import re 6import configparser 7 8from typing import Any, cast, Dict, Optional, List, Union, Tuple 9from PyQt5.QtWidgets import QMessageBox 10 11from UM.Decorators import override 12from UM.Settings.ContainerFormatError import ContainerFormatError 13from UM.Settings.Interfaces import ContainerInterface 14from UM.Settings.ContainerRegistry import ContainerRegistry 15from UM.Settings.ContainerStack import ContainerStack 16from UM.Settings.InstanceContainer import InstanceContainer 17from UM.Settings.SettingInstance import SettingInstance 18from UM.Logger import Logger 19from UM.Message import Message 20from UM.Platform import Platform 21from UM.PluginRegistry import PluginRegistry # For getting the possible profile writers to write with. 22from UM.Resources import Resources 23from UM.Util import parseBool 24from cura.ReaderWriters.ProfileWriter import ProfileWriter 25 26from . import ExtruderStack 27from . import GlobalStack 28 29import cura.CuraApplication 30from cura.Settings.cura_empty_instance_containers import empty_quality_container 31from cura.Machines.ContainerTree import ContainerTree 32from cura.ReaderWriters.ProfileReader import NoProfileException, ProfileReader 33 34from UM.i18n import i18nCatalog 35catalog = i18nCatalog("cura") 36 37 38class CuraContainerRegistry(ContainerRegistry): 39 def __init__(self, *args, **kwargs): 40 super().__init__(*args, **kwargs) 41 42 # We don't have all the machines loaded in the beginning, so in order to add the missing extruder stack 43 # for single extrusion machines, we subscribe to the containerAdded signal, and whenever a global stack 44 # is added, we check to see if an extruder stack needs to be added. 45 self.containerAdded.connect(self._onContainerAdded) 46 47 @override(ContainerRegistry) 48 def addContainer(self, container: ContainerInterface) -> bool: 49 """Overridden from ContainerRegistry 50 51 Adds a container to the registry. 52 53 This will also try to convert a ContainerStack to either Extruder or 54 Global stack based on metadata information. 55 """ 56 57 # Note: Intentional check with type() because we want to ignore subclasses 58 if type(container) == ContainerStack: 59 container = self._convertContainerStack(cast(ContainerStack, container)) 60 61 if isinstance(container, InstanceContainer) and type(container) != type(self.getEmptyInstanceContainer()): 62 # Check against setting version of the definition. 63 required_setting_version = cura.CuraApplication.CuraApplication.SettingVersion 64 actual_setting_version = int(container.getMetaDataEntry("setting_version", default = 0)) 65 if required_setting_version != actual_setting_version: 66 Logger.log("w", "Instance container {container_id} is outdated. Its setting version is {actual_setting_version} but it should be {required_setting_version}.".format(container_id = container.getId(), actual_setting_version = actual_setting_version, required_setting_version = required_setting_version)) 67 return False # Don't add. 68 69 return super().addContainer(container) 70 71 def createUniqueName(self, container_type: str, current_name: str, new_name: str, fallback_name: str) -> str: 72 """Create a name that is not empty and unique 73 74 :param container_type: :type{string} Type of the container (machine, quality, ...) 75 :param current_name: :type{} Current name of the container, which may be an acceptable option 76 :param new_name: :type{string} Base name, which may not be unique 77 :param fallback_name: :type{string} Name to use when (stripped) new_name is empty 78 :return: :type{string} Name that is unique for the specified type and name/id 79 """ 80 new_name = new_name.strip() 81 num_check = re.compile(r"(.*?)\s*#\d+$").match(new_name) 82 if num_check: 83 new_name = num_check.group(1) 84 if new_name == "": 85 new_name = fallback_name 86 87 unique_name = new_name 88 i = 1 89 # In case we are renaming, the current name of the container is also a valid end-result 90 while self._containerExists(container_type, unique_name) and unique_name != current_name: 91 i += 1 92 unique_name = "%s #%d" % (new_name, i) 93 94 return unique_name 95 96 def _containerExists(self, container_type: str, container_name: str): 97 """Check if a container with of a certain type and a certain name or id exists 98 99 Both the id and the name are checked, because they may not be the same and it is better if they are both unique 100 :param container_type: :type{string} Type of the container (machine, quality, ...) 101 :param container_name: :type{string} Name to check 102 """ 103 container_class = ContainerStack if container_type == "machine" else InstanceContainer 104 105 return self.findContainersMetadata(container_type = container_class, id = container_name, type = container_type, ignore_case = True) or \ 106 self.findContainersMetadata(container_type = container_class, name = container_name, type = container_type) 107 108 def exportQualityProfile(self, container_list: List[InstanceContainer], file_name: str, file_type: str) -> bool: 109 """Exports an profile to a file 110 111 :param container_list: :type{list} the containers to export. This is not 112 necessarily in any order! 113 :param file_name: :type{str} the full path and filename to export to. 114 :param file_type: :type{str} the file type with the format "<description> (*.<extension>)" 115 :return: True if the export succeeded, false otherwise. 116 """ 117 118 # Parse the fileType to deduce what plugin can save the file format. 119 # fileType has the format "<description> (*.<extension>)" 120 split = file_type.rfind(" (*.") # Find where the description ends and the extension starts. 121 if split < 0: # Not found. Invalid format. 122 Logger.log("e", "Invalid file format identifier %s", file_type) 123 return False 124 description = file_type[:split] 125 extension = file_type[split + 4:-1] # Leave out the " (*." and ")". 126 if not file_name.endswith("." + extension): # Auto-fill the extension if the user did not provide any. 127 file_name += "." + extension 128 129 # On Windows, QML FileDialog properly asks for overwrite confirm, but not on other platforms, so handle those ourself. 130 if not Platform.isWindows(): 131 if os.path.exists(file_name): 132 result = QMessageBox.question(None, catalog.i18nc("@title:window", "File Already Exists"), 133 catalog.i18nc("@label Don't translate the XML tag <filename>!", "The file <filename>{0}</filename> already exists. Are you sure you want to overwrite it?").format(file_name)) 134 if result == QMessageBox.No: 135 return False 136 137 profile_writer = self._findProfileWriter(extension, description) 138 try: 139 if profile_writer is None: 140 raise Exception("Unable to find a profile writer") 141 success = profile_writer.write(file_name, container_list) 142 except Exception as e: 143 Logger.log("e", "Failed to export profile to %s: %s", file_name, str(e)) 144 m = Message(catalog.i18nc("@info:status Don't translate the XML tags <filename> or <message>!", "Failed to export profile to <filename>{0}</filename>: <message>{1}</message>", file_name, str(e)), 145 lifetime = 0, 146 title = catalog.i18nc("@info:title", "Error")) 147 m.show() 148 return False 149 if not success: 150 Logger.log("w", "Failed to export profile to %s: Writer plugin reported failure.", file_name) 151 m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Failed to export profile to <filename>{0}</filename>: Writer plugin reported failure.", file_name), 152 lifetime = 0, 153 title = catalog.i18nc("@info:title", "Error")) 154 m.show() 155 return False 156 m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Exported profile to <filename>{0}</filename>", file_name), 157 title = catalog.i18nc("@info:title", "Export succeeded")) 158 m.show() 159 return True 160 161 def _findProfileWriter(self, extension: str, description: str) -> Optional[ProfileWriter]: 162 """Gets the plugin object matching the criteria 163 164 :param extension: 165 :param description: 166 :return: The plugin object matching the given extension and description. 167 """ 168 plugin_registry = PluginRegistry.getInstance() 169 for plugin_id, meta_data in self._getIOPlugins("profile_writer"): 170 for supported_type in meta_data["profile_writer"]: # All file types this plugin can supposedly write. 171 supported_extension = supported_type.get("extension", None) 172 if supported_extension == extension: # This plugin supports a file type with the same extension. 173 supported_description = supported_type.get("description", None) 174 if supported_description == description: # The description is also identical. Assume it's the same file type. 175 return cast(ProfileWriter, plugin_registry.getPluginObject(plugin_id)) 176 return None 177 178 def importProfile(self, file_name: str) -> Dict[str, str]: 179 """Imports a profile from a file 180 181 :param file_name: The full path and filename of the profile to import. 182 :return: Dict with a 'status' key containing the string 'ok', 'warning' or 'error', 183 and a 'message' key containing a message for the user. 184 """ 185 186 Logger.log("d", "Attempting to import profile %s", file_name) 187 if not file_name: 188 return { "status": "error", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "Failed to import profile from <filename>{0}</filename>: {1}", file_name, "Invalid path")} 189 190 global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() 191 if not global_stack: 192 return {"status": "error", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "Can't import profile from <filename>{0}</filename> before a printer is added.", file_name)} 193 container_tree = ContainerTree.getInstance() 194 195 machine_extruders = global_stack.extruderList 196 197 plugin_registry = PluginRegistry.getInstance() 198 extension = file_name.split(".")[-1] 199 200 for plugin_id, meta_data in self._getIOPlugins("profile_reader"): 201 if meta_data["profile_reader"][0]["extension"] != extension: 202 continue 203 profile_reader = cast(ProfileReader, plugin_registry.getPluginObject(plugin_id)) 204 try: 205 profile_or_list = profile_reader.read(file_name) # Try to open the file with the profile reader. 206 except NoProfileException: 207 return { "status": "ok", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "No custom profile to import in file <filename>{0}</filename>", file_name)} 208 except Exception as e: 209 # Note that this will fail quickly. That is, if any profile reader throws an exception, it will stop reading. It will only continue reading if the reader returned None. 210 Logger.log("e", "Failed to import profile from %s: %s while using profile reader. Got exception %s", file_name, profile_reader.getPluginId(), str(e)) 211 return { "status": "error", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "Failed to import profile from <filename>{0}</filename>:", file_name) + "\n<message>" + str(e) + "</message>"} 212 213 if profile_or_list: 214 # Ensure it is always a list of profiles 215 if not isinstance(profile_or_list, list): 216 profile_or_list = [profile_or_list] 217 218 # First check if this profile is suitable for this machine 219 global_profile = None 220 extruder_profiles = [] 221 if len(profile_or_list) == 1: 222 global_profile = profile_or_list[0] 223 else: 224 for profile in profile_or_list: 225 if not profile.getMetaDataEntry("position"): 226 global_profile = profile 227 else: 228 extruder_profiles.append(profile) 229 extruder_profiles = sorted(extruder_profiles, key = lambda x: int(x.getMetaDataEntry("position", default = "0"))) 230 profile_or_list = [global_profile] + extruder_profiles 231 232 if not global_profile: 233 Logger.log("e", "Incorrect profile [%s]. Could not find global profile", file_name) 234 return { "status": "error", 235 "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "This profile <filename>{0}</filename> contains incorrect data, could not import it.", file_name)} 236 profile_definition = global_profile.getMetaDataEntry("definition") 237 238 # Make sure we have a profile_definition in the file: 239 if profile_definition is None: 240 break 241 machine_definitions = self.findContainers(id = profile_definition) 242 if not machine_definitions: 243 Logger.log("e", "Incorrect profile [%s]. Unknown machine type [%s]", file_name, profile_definition) 244 return {"status": "error", 245 "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "This profile <filename>{0}</filename> contains incorrect data, could not import it.", file_name) 246 } 247 machine_definition = machine_definitions[0] 248 249 # Get the expected machine definition. 250 # i.e.: We expect gcode for a UM2 Extended to be defined as normal UM2 gcode... 251 has_machine_quality = parseBool(machine_definition.getMetaDataEntry("has_machine_quality", "false")) 252 profile_definition = machine_definition.getMetaDataEntry("quality_definition", machine_definition.getId()) if has_machine_quality else "fdmprinter" 253 expected_machine_definition = container_tree.machines[global_stack.definition.getId()].quality_definition 254 255 # And check if the profile_definition matches either one (showing error if not): 256 if profile_definition != expected_machine_definition: 257 Logger.log("d", "Profile {file_name} is for machine {profile_definition}, but the current active machine is {expected_machine_definition}. Changing profile's definition.".format(file_name = file_name, profile_definition = profile_definition, expected_machine_definition = expected_machine_definition)) 258 global_profile.setMetaDataEntry("definition", expected_machine_definition) 259 for extruder_profile in extruder_profiles: 260 extruder_profile.setMetaDataEntry("definition", expected_machine_definition) 261 262 quality_name = global_profile.getName() 263 quality_type = global_profile.getMetaDataEntry("quality_type") 264 265 name_seed = os.path.splitext(os.path.basename(file_name))[0] 266 new_name = self.uniqueName(name_seed) 267 268 # Ensure it is always a list of profiles 269 if type(profile_or_list) is not list: 270 profile_or_list = [profile_or_list] 271 272 # Make sure that there are also extruder stacks' quality_changes, not just one for the global stack 273 if len(profile_or_list) == 1: 274 global_profile = profile_or_list[0] 275 extruder_profiles = [] 276 for idx, extruder in enumerate(global_stack.extruderList): 277 profile_id = ContainerRegistry.getInstance().uniqueName(global_stack.getId() + "_extruder_" + str(idx + 1)) 278 profile = InstanceContainer(profile_id) 279 profile.setName(quality_name) 280 profile.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.SettingVersion) 281 profile.setMetaDataEntry("type", "quality_changes") 282 profile.setMetaDataEntry("definition", expected_machine_definition) 283 profile.setMetaDataEntry("quality_type", quality_type) 284 profile.setDirty(True) 285 if idx == 0: 286 # Move all per-extruder settings to the first extruder's quality_changes 287 for qc_setting_key in global_profile.getAllKeys(): 288 settable_per_extruder = global_stack.getProperty(qc_setting_key, "settable_per_extruder") 289 if settable_per_extruder: 290 setting_value = global_profile.getProperty(qc_setting_key, "value") 291 292 setting_definition = global_stack.getSettingDefinition(qc_setting_key) 293 if setting_definition is not None: 294 new_instance = SettingInstance(setting_definition, profile) 295 new_instance.setProperty("value", setting_value) 296 new_instance.resetState() # Ensure that the state is not seen as a user state. 297 profile.addInstance(new_instance) 298 profile.setDirty(True) 299 300 global_profile.removeInstance(qc_setting_key, postpone_emit = True) 301 extruder_profiles.append(profile) 302 303 for profile in extruder_profiles: 304 profile_or_list.append(profile) 305 306 # Import all profiles 307 profile_ids_added = [] # type: List[str] 308 additional_message = None 309 for profile_index, profile in enumerate(profile_or_list): 310 if profile_index == 0: 311 # This is assumed to be the global profile 312 profile_id = (cast(ContainerInterface, global_stack.getBottom()).getId() + "_" + name_seed).lower().replace(" ", "_") 313 314 elif profile_index < len(machine_extruders) + 1: 315 # This is assumed to be an extruder profile 316 extruder_id = machine_extruders[profile_index - 1].definition.getId() 317 extruder_position = str(profile_index - 1) 318 if not profile.getMetaDataEntry("position"): 319 profile.setMetaDataEntry("position", extruder_position) 320 else: 321 profile.setMetaDataEntry("position", extruder_position) 322 profile_id = (extruder_id + "_" + name_seed).lower().replace(" ", "_") 323 324 else: # More extruders in the imported file than in the machine. 325 continue # Delete the additional profiles. 326 327 configuration_successful, message = self._configureProfile(profile, profile_id, new_name, expected_machine_definition) 328 if configuration_successful: 329 additional_message = message 330 else: 331 # Remove any profiles that were added. 332 for profile_id in profile_ids_added + [profile.getId()]: 333 self.removeContainer(profile_id) 334 if not message: 335 message = "" 336 return {"status": "error", "message": catalog.i18nc( 337 "@info:status Don't translate the XML tag <filename>!", 338 "Failed to import profile from <filename>{0}</filename>:", 339 file_name) + " " + message} 340 profile_ids_added.append(profile.getId()) 341 result_status = "ok" 342 success_message = catalog.i18nc("@info:status", "Successfully imported profile {0}.", profile_or_list[0].getName()) 343 if additional_message: 344 result_status = "warning" 345 success_message += additional_message 346 return {"status": result_status, "message": success_message} 347 348 # This message is throw when the profile reader doesn't find any profile in the file 349 return {"status": "error", "message": catalog.i18nc("@info:status", "File {0} does not contain any valid profile.", file_name)} 350 351 # If it hasn't returned by now, none of the plugins loaded the profile successfully. 352 return {"status": "error", "message": catalog.i18nc("@info:status", "Profile {0} has an unknown file type or is corrupted.", file_name)} 353 354 @override(ContainerRegistry) 355 def load(self) -> None: 356 super().load() 357 self._registerSingleExtrusionMachinesExtruderStacks() 358 self._connectUpgradedExtruderStacksToMachines() 359 360 @override(ContainerRegistry) 361 def loadAllMetadata(self) -> None: 362 super().loadAllMetadata() 363 self._cleanUpInvalidQualityChanges() 364 365 def _cleanUpInvalidQualityChanges(self) -> None: 366 # We've seen cases where it was possible for quality_changes to be incorrectly added. This is to ensure that 367 # any such leftovers are purged from the registry. 368 quality_changes = ContainerRegistry.getInstance().findContainersMetadata(type="quality_changes") 369 370 profile_count_by_name = {} # type: Dict[str, int] 371 372 for quality_change in quality_changes: 373 name = str(quality_change.get("name", "")) 374 if name == "empty": 375 continue 376 if name not in profile_count_by_name: 377 profile_count_by_name[name] = 0 378 profile_count_by_name[name] += 1 379 380 for profile_name, profile_count in profile_count_by_name.items(): 381 if profile_count > 1: 382 continue 383 # Only one profile found, this should not ever be the case, so that profile needs to be removed! 384 Logger.log("d", "Found an invalid quality_changes profile with the name %s. Going to remove that now", profile_name) 385 invalid_quality_changes = ContainerRegistry.getInstance().findContainersMetadata(name=profile_name) 386 self.removeContainer(invalid_quality_changes[0]["id"]) 387 388 @override(ContainerRegistry) 389 def _isMetadataValid(self, metadata: Optional[Dict[str, Any]]) -> bool: 390 """Check if the metadata for a container is okay before adding it. 391 392 This overrides the one from UM.Settings.ContainerRegistry because we 393 also require that the setting_version is correct. 394 """ 395 396 if metadata is None: 397 return False 398 if "setting_version" not in metadata: 399 return False 400 try: 401 if int(metadata["setting_version"]) != cura.CuraApplication.CuraApplication.SettingVersion: 402 return False 403 except ValueError: #Not parsable as int. 404 return False 405 return True 406 407 def _configureProfile(self, profile: InstanceContainer, id_seed: str, new_name: str, machine_definition_id: str) -> Tuple[bool, Optional[str]]: 408 """Update an imported profile to match the current machine configuration. 409 410 :param profile: The profile to configure. 411 :param id_seed: The base ID for the profile. May be changed so it does not conflict with existing containers. 412 :param new_name: The new name for the profile. 413 414 :returns: tuple (configuration_successful, message) 415 WHERE 416 bool configuration_successful: Whether the process of configuring the profile was successful 417 optional str message: A message indicating the outcome of configuring the profile. If the configuration 418 is successful, this message can be None or contain a warning 419 """ 420 421 profile.setDirty(True) # Ensure the profiles are correctly saved 422 423 new_id = self.createUniqueName("quality_changes", "", id_seed, catalog.i18nc("@label", "Custom profile")) 424 profile.setMetaDataEntry("id", new_id) 425 profile.setName(new_name) 426 427 # Set the unique Id to the profile, so it's generating a new one even if the user imports the same profile 428 # It also solves an issue with importing profiles from G-Codes 429 profile.setMetaDataEntry("id", new_id) 430 profile.setMetaDataEntry("definition", machine_definition_id) 431 432 if "type" in profile.getMetaData(): 433 profile.setMetaDataEntry("type", "quality_changes") 434 else: 435 profile.setMetaDataEntry("type", "quality_changes") 436 437 quality_type = profile.getMetaDataEntry("quality_type") 438 if not quality_type: 439 return False, catalog.i18nc("@info:status", "Profile is missing a quality type.") 440 441 global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() 442 if not global_stack: 443 return False, catalog.i18nc("@info:status", "Global stack is missing.") 444 445 definition_id = ContainerTree.getInstance().machines[global_stack.definition.getId()].quality_definition 446 profile.setDefinition(definition_id) 447 448 if not self.addContainer(profile): 449 return False, catalog.i18nc("@info:status", "Unable to add the profile.") 450 451 # "not_supported" profiles can be imported. 452 if quality_type == empty_quality_container.getMetaDataEntry("quality_type"): 453 return True, None 454 455 # Check to make sure the imported profile actually makes sense in context of the current configuration. 456 # This prevents issues where importing a "draft" profile for a machine without "draft" qualities would report as 457 # successfully imported but then fail to show up. 458 available_quality_groups_dict = {name: quality_group for name, quality_group in ContainerTree.getInstance().getCurrentQualityGroups().items() if quality_group.is_available} 459 all_quality_groups_dict = ContainerTree.getInstance().getCurrentQualityGroups() 460 461 # If the quality type doesn't exist at all in the quality_groups of this machine, reject the profile 462 if quality_type not in all_quality_groups_dict: 463 return False, catalog.i18nc("@info:status", "Quality type '{0}' is not compatible with the current active machine definition '{1}'.", quality_type, definition_id) 464 465 # If the quality_type exists in the quality_groups of this printer but it is not available with the current 466 # machine configuration (e.g. not available for the selected nozzles), accept it with a warning 467 if quality_type not in available_quality_groups_dict: 468 return True, "\n\n" + catalog.i18nc("@info:status", "Warning: The profile is not visible because its quality type '{0}' is not available for the current configuration. " 469 "Switch to a material/nozzle combination that can use this quality type.", quality_type) 470 471 return True, None 472 473 @override(ContainerRegistry) 474 def saveDirtyContainers(self) -> None: 475 # Lock file for "more" atomically loading and saving to/from config dir. 476 with self.lockFile(): 477 # Save base files first 478 for instance in self.findDirtyContainers(container_type=InstanceContainer): 479 if instance.getMetaDataEntry("removed"): 480 continue 481 if instance.getId() == instance.getMetaData().get("base_file"): 482 self.saveContainer(instance) 483 484 for instance in self.findDirtyContainers(container_type=InstanceContainer): 485 if instance.getMetaDataEntry("removed"): 486 continue 487 self.saveContainer(instance) 488 489 for stack in self.findContainerStacks(): 490 self.saveContainer(stack) 491 492 def _getIOPlugins(self, io_type): 493 """Gets a list of profile writer plugins 494 495 :return: List of tuples of (plugin_id, meta_data). 496 """ 497 plugin_registry = PluginRegistry.getInstance() 498 active_plugin_ids = plugin_registry.getActivePlugins() 499 500 result = [] 501 for plugin_id in active_plugin_ids: 502 meta_data = plugin_registry.getMetaData(plugin_id) 503 if io_type in meta_data: 504 result.append( (plugin_id, meta_data) ) 505 return result 506 507 def _convertContainerStack(self, container: ContainerStack) -> Union[ExtruderStack.ExtruderStack, GlobalStack.GlobalStack]: 508 """Convert an "old-style" pure ContainerStack to either an Extruder or Global stack.""" 509 510 assert type(container) == ContainerStack 511 512 container_type = container.getMetaDataEntry("type") 513 if container_type not in ("extruder_train", "machine"): 514 # It is not an extruder or machine, so do nothing with the stack 515 return container 516 517 Logger.log("d", "Converting ContainerStack {stack} to {type}", stack = container.getId(), type = container_type) 518 519 if container_type == "extruder_train": 520 new_stack = ExtruderStack.ExtruderStack(container.getId()) 521 else: 522 new_stack = GlobalStack.GlobalStack(container.getId()) 523 524 container_contents = container.serialize() 525 new_stack.deserialize(container_contents) 526 527 # Delete the old configuration file so we do not get double stacks 528 if os.path.isfile(container.getPath()): 529 os.remove(container.getPath()) 530 531 return new_stack 532 533 def _registerSingleExtrusionMachinesExtruderStacks(self) -> None: 534 machines = self.findContainerStacks(type = "machine", machine_extruder_trains = {"0": "fdmextruder"}) 535 for machine in machines: 536 extruder_stacks = self.findContainerStacks(type = "extruder_train", machine = machine.getId()) 537 if not extruder_stacks: 538 self.addExtruderStackForSingleExtrusionMachine(machine, "fdmextruder") 539 540 def _onContainerAdded(self, container: ContainerInterface) -> None: 541 # We don't have all the machines loaded in the beginning, so in order to add the missing extruder stack 542 # for single extrusion machines, we subscribe to the containerAdded signal, and whenever a global stack 543 # is added, we check to see if an extruder stack needs to be added. 544 if not isinstance(container, ContainerStack) or container.getMetaDataEntry("type") != "machine": 545 return 546 547 machine_extruder_trains = container.getMetaDataEntry("machine_extruder_trains") 548 if machine_extruder_trains is not None and machine_extruder_trains != {"0": "fdmextruder"}: 549 return 550 551 extruder_stacks = self.findContainerStacks(type = "extruder_train", machine = container.getId()) 552 if not extruder_stacks: 553 self.addExtruderStackForSingleExtrusionMachine(container, "fdmextruder") 554 555 # 556 # new_global_quality_changes is optional. It is only used in project loading for a scenario like this: 557 # - override the current machine 558 # - create new for custom quality profile 559 # new_global_quality_changes is the new global quality changes container in this scenario. 560 # create_new_ids indicates if new unique ids must be created 561 # 562 def addExtruderStackForSingleExtrusionMachine(self, machine, extruder_id, new_global_quality_changes = None, create_new_ids = True): 563 new_extruder_id = extruder_id 564 565 application = cura.CuraApplication.CuraApplication.getInstance() 566 567 extruder_definitions = self.findDefinitionContainers(id = new_extruder_id) 568 if not extruder_definitions: 569 Logger.log("w", "Could not find definition containers for extruder %s", new_extruder_id) 570 return 571 572 extruder_definition = extruder_definitions[0] 573 unique_name = self.uniqueName(machine.getName() + " " + new_extruder_id) if create_new_ids else machine.getName() + " " + new_extruder_id 574 575 extruder_stack = ExtruderStack.ExtruderStack(unique_name) 576 extruder_stack.setName(extruder_definition.getName()) 577 extruder_stack.setDefinition(extruder_definition) 578 extruder_stack.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position")) 579 580 # create a new definition_changes container for the extruder stack 581 definition_changes_id = self.uniqueName(extruder_stack.getId() + "_settings") if create_new_ids else extruder_stack.getId() + "_settings" 582 definition_changes_name = definition_changes_id 583 definition_changes = InstanceContainer(definition_changes_id, parent = application) 584 definition_changes.setName(definition_changes_name) 585 definition_changes.setMetaDataEntry("setting_version", application.SettingVersion) 586 definition_changes.setMetaDataEntry("type", "definition_changes") 587 definition_changes.setMetaDataEntry("definition", extruder_definition.getId()) 588 589 # move definition_changes settings if exist 590 for setting_key in definition_changes.getAllKeys(): 591 if machine.definition.getProperty(setting_key, "settable_per_extruder"): 592 setting_value = machine.definitionChanges.getProperty(setting_key, "value") 593 if setting_value is not None: 594 # move it to the extruder stack's definition_changes 595 setting_definition = machine.getSettingDefinition(setting_key) 596 new_instance = SettingInstance(setting_definition, definition_changes) 597 new_instance.setProperty("value", setting_value) 598 new_instance.resetState() # Ensure that the state is not seen as a user state. 599 definition_changes.addInstance(new_instance) 600 definition_changes.setDirty(True) 601 602 machine.definitionChanges.removeInstance(setting_key, postpone_emit = True) 603 604 self.addContainer(definition_changes) 605 extruder_stack.setDefinitionChanges(definition_changes) 606 607 # create empty user changes container otherwise 608 user_container_id = self.uniqueName(extruder_stack.getId() + "_user") if create_new_ids else extruder_stack.getId() + "_user" 609 user_container_name = user_container_id 610 user_container = InstanceContainer(user_container_id, parent = application) 611 user_container.setName(user_container_name) 612 user_container.setMetaDataEntry("type", "user") 613 user_container.setMetaDataEntry("machine", machine.getId()) 614 user_container.setMetaDataEntry("setting_version", application.SettingVersion) 615 user_container.setDefinition(machine.definition.getId()) 616 user_container.setMetaDataEntry("position", extruder_stack.getMetaDataEntry("position")) 617 618 if machine.userChanges: 619 # For the newly created extruder stack, we need to move all "per-extruder" settings to the user changes 620 # container to the extruder stack. 621 for user_setting_key in machine.userChanges.getAllKeys(): 622 settable_per_extruder = machine.getProperty(user_setting_key, "settable_per_extruder") 623 if settable_per_extruder: 624 setting_value = machine.getProperty(user_setting_key, "value") 625 626 setting_definition = machine.getSettingDefinition(user_setting_key) 627 new_instance = SettingInstance(setting_definition, definition_changes) 628 new_instance.setProperty("value", setting_value) 629 new_instance.resetState() # Ensure that the state is not seen as a user state. 630 user_container.addInstance(new_instance) 631 user_container.setDirty(True) 632 633 machine.userChanges.removeInstance(user_setting_key, postpone_emit = True) 634 635 self.addContainer(user_container) 636 extruder_stack.setUserChanges(user_container) 637 638 empty_variant = application.empty_variant_container 639 empty_material = application.empty_material_container 640 empty_quality = application.empty_quality_container 641 642 if machine.variant.getId() not in ("empty", "empty_variant"): 643 variant = machine.variant 644 else: 645 variant = empty_variant 646 extruder_stack.variant = variant 647 648 if machine.material.getId() not in ("empty", "empty_material"): 649 material = machine.material 650 else: 651 material = empty_material 652 extruder_stack.material = material 653 654 if machine.quality.getId() not in ("empty", "empty_quality"): 655 quality = machine.quality 656 else: 657 quality = empty_quality 658 extruder_stack.quality = quality 659 660 machine_quality_changes = machine.qualityChanges 661 if new_global_quality_changes is not None: 662 machine_quality_changes = new_global_quality_changes 663 664 if machine_quality_changes.getId() not in ("empty", "empty_quality_changes"): 665 extruder_quality_changes_container = self.findInstanceContainers(name = machine_quality_changes.getName(), extruder = extruder_id) 666 if extruder_quality_changes_container: 667 extruder_quality_changes_container = extruder_quality_changes_container[0] 668 669 quality_changes_id = extruder_quality_changes_container.getId() 670 extruder_stack.qualityChanges = self.findInstanceContainers(id = quality_changes_id)[0] 671 else: 672 # Some extruder quality_changes containers can be created at runtime as files in the qualities 673 # folder. Those files won't be loaded in the registry immediately. So we also need to search 674 # the folder to see if the quality_changes exists. 675 extruder_quality_changes_container = self._findQualityChangesContainerInCuraFolder(machine_quality_changes.getName()) 676 if extruder_quality_changes_container: 677 quality_changes_id = extruder_quality_changes_container.getId() 678 extruder_quality_changes_container.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position")) 679 extruder_stack.qualityChanges = self.findInstanceContainers(id = quality_changes_id)[0] 680 else: 681 # If we still cannot find a quality changes container for the extruder, create a new one 682 container_name = machine_quality_changes.getName() 683 container_id = self.uniqueName(extruder_stack.getId() + "_qc_" + container_name) 684 extruder_quality_changes_container = InstanceContainer(container_id, parent = application) 685 extruder_quality_changes_container.setName(container_name) 686 extruder_quality_changes_container.setMetaDataEntry("type", "quality_changes") 687 extruder_quality_changes_container.setMetaDataEntry("setting_version", application.SettingVersion) 688 extruder_quality_changes_container.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position")) 689 extruder_quality_changes_container.setMetaDataEntry("quality_type", machine_quality_changes.getMetaDataEntry("quality_type")) 690 extruder_quality_changes_container.setMetaDataEntry("intent_category", "default") # Intent categories weren't a thing back then. 691 extruder_quality_changes_container.setDefinition(machine_quality_changes.getDefinition().getId()) 692 693 self.addContainer(extruder_quality_changes_container) 694 extruder_stack.qualityChanges = extruder_quality_changes_container 695 696 if not extruder_quality_changes_container: 697 Logger.log("w", "Could not find quality_changes named [%s] for extruder [%s]", 698 machine_quality_changes.getName(), extruder_stack.getId()) 699 else: 700 # Move all per-extruder settings to the extruder's quality changes 701 for qc_setting_key in machine_quality_changes.getAllKeys(): 702 settable_per_extruder = machine.getProperty(qc_setting_key, "settable_per_extruder") 703 if settable_per_extruder: 704 setting_value = machine_quality_changes.getProperty(qc_setting_key, "value") 705 706 setting_definition = machine.getSettingDefinition(qc_setting_key) 707 new_instance = SettingInstance(setting_definition, definition_changes) 708 new_instance.setProperty("value", setting_value) 709 new_instance.resetState() # Ensure that the state is not seen as a user state. 710 extruder_quality_changes_container.addInstance(new_instance) 711 extruder_quality_changes_container.setDirty(True) 712 713 machine_quality_changes.removeInstance(qc_setting_key, postpone_emit=True) 714 else: 715 extruder_stack.qualityChanges = self.findInstanceContainers(id = "empty_quality_changes")[0] 716 717 self.addContainer(extruder_stack) 718 719 # Also need to fix the other qualities that are suitable for this machine. Those quality changes may still have 720 # per-extruder settings in the container for the machine instead of the extruder. 721 if machine_quality_changes.getId() not in ("empty", "empty_quality_changes"): 722 quality_changes_machine_definition_id = machine_quality_changes.getDefinition().getId() 723 else: 724 whole_machine_definition = machine.definition 725 machine_entry = machine.definition.getMetaDataEntry("machine") 726 if machine_entry is not None: 727 container_registry = ContainerRegistry.getInstance() 728 whole_machine_definition = container_registry.findDefinitionContainers(id = machine_entry)[0] 729 730 quality_changes_machine_definition_id = "fdmprinter" 731 if whole_machine_definition.getMetaDataEntry("has_machine_quality"): 732 quality_changes_machine_definition_id = machine.definition.getMetaDataEntry("quality_definition", 733 whole_machine_definition.getId()) 734 qcs = self.findInstanceContainers(type = "quality_changes", definition = quality_changes_machine_definition_id) 735 qc_groups = {} # map of qc names -> qc containers 736 for qc in qcs: 737 qc_name = qc.getName() 738 if qc_name not in qc_groups: 739 qc_groups[qc_name] = [] 740 qc_groups[qc_name].append(qc) 741 # Try to find from the quality changes cura directory too 742 quality_changes_container = self._findQualityChangesContainerInCuraFolder(machine_quality_changes.getName()) 743 if quality_changes_container: 744 qc_groups[qc_name].append(quality_changes_container) 745 746 for qc_name, qc_list in qc_groups.items(): 747 qc_dict = {"global": None, "extruders": []} 748 for qc in qc_list: 749 extruder_position = qc.getMetaDataEntry("position") 750 if extruder_position is not None: 751 qc_dict["extruders"].append(qc) 752 else: 753 qc_dict["global"] = qc 754 if qc_dict["global"] is not None and len(qc_dict["extruders"]) == 1: 755 # Move per-extruder settings 756 for qc_setting_key in qc_dict["global"].getAllKeys(): 757 settable_per_extruder = machine.getProperty(qc_setting_key, "settable_per_extruder") 758 if settable_per_extruder: 759 setting_value = qc_dict["global"].getProperty(qc_setting_key, "value") 760 761 setting_definition = machine.getSettingDefinition(qc_setting_key) 762 new_instance = SettingInstance(setting_definition, definition_changes) 763 new_instance.setProperty("value", setting_value) 764 new_instance.resetState() # Ensure that the state is not seen as a user state. 765 qc_dict["extruders"][0].addInstance(new_instance) 766 qc_dict["extruders"][0].setDirty(True) 767 768 qc_dict["global"].removeInstance(qc_setting_key, postpone_emit=True) 769 770 # Set next stack at the end 771 extruder_stack.setNextStack(machine) 772 773 return extruder_stack 774 775 def _findQualityChangesContainerInCuraFolder(self, name: str) -> Optional[InstanceContainer]: 776 quality_changes_dir = Resources.getPath(cura.CuraApplication.CuraApplication.ResourceTypes.QualityChangesInstanceContainer) 777 778 instance_container = None 779 780 for item in os.listdir(quality_changes_dir): 781 file_path = os.path.join(quality_changes_dir, item) 782 if not os.path.isfile(file_path): 783 continue 784 785 parser = configparser.ConfigParser(interpolation = None) 786 try: 787 parser.read([file_path]) 788 except Exception: 789 # Skip, it is not a valid stack file 790 continue 791 792 if not parser.has_option("general", "name"): 793 continue 794 795 if parser["general"]["name"] == name: 796 # Load the container 797 container_id = os.path.basename(file_path).replace(".inst.cfg", "") 798 if self.findInstanceContainers(id = container_id): 799 # This container is already in the registry, skip it 800 continue 801 802 instance_container = InstanceContainer(container_id) 803 with open(file_path, "r", encoding = "utf-8") as f: 804 serialized = f.read() 805 try: 806 instance_container.deserialize(serialized, file_path) 807 except ContainerFormatError: 808 Logger.logException("e", "Unable to deserialize InstanceContainer %s", file_path) 809 continue 810 self.addContainer(instance_container) 811 break 812 813 return instance_container 814 815 # Fix the extruders that were upgraded to ExtruderStack instances during addContainer. 816 # The stacks are now responsible for setting the next stack on deserialize. However, 817 # due to problems with loading order, some stacks may not have the proper next stack 818 # set after upgrading, because the proper global stack was not yet loaded. This method 819 # makes sure those extruders also get the right stack set. 820 def _connectUpgradedExtruderStacksToMachines(self) -> None: 821 extruder_stacks = self.findContainers(container_type = ExtruderStack.ExtruderStack) 822 for extruder_stack in extruder_stacks: 823 if extruder_stack.getNextStack(): 824 # Has the right next stack, so ignore it. 825 continue 826 827 machines = ContainerRegistry.getInstance().findContainerStacks(id = extruder_stack.getMetaDataEntry("machine", "")) 828 if machines: 829 extruder_stack.setNextStack(machines[0]) 830 else: 831 Logger.log("w", "Could not find machine {machine} for extruder {extruder}", machine = extruder_stack.getMetaDataEntry("machine"), extruder = extruder_stack.getId()) 832 833 # Override just for the type. 834 @classmethod 835 @override(ContainerRegistry) 836 def getInstance(cls, *args, **kwargs) -> "CuraContainerRegistry": 837 return cast(CuraContainerRegistry, super().getInstance(*args, **kwargs)) 838