diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index 20e54fa57c..6132d8ab36 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -601,7 +601,9 @@ class CuraApplication(QtApplication): preferences.addPreference("mesh/scale_to_fit", False) preferences.addPreference("mesh/scale_tiny_meshes", True) preferences.addPreference("cura/dialog_on_project_save", True) + preferences.addPreference("cura/dialog_on_ucp_project_save", True) preferences.addPreference("cura/asked_dialog_on_project_save", False) + preferences.addPreference("cura/asked_dialog_on_ucp_project_save", False) preferences.addPreference("cura/choice_on_profile_override", "always_ask") preferences.addPreference("cura/choice_on_open_project", "always_ask") preferences.addPreference("cura/use_multi_build_plate", False) @@ -617,6 +619,7 @@ class CuraApplication(QtApplication): preferences.addPreference("view/invert_zoom", False) preferences.addPreference("view/filter_current_build_plate", False) + preferences.addPreference("view/navigation_style", "cura") preferences.addPreference("cura/sidebar_collapsed", False) preferences.addPreference("cura/favorite_materials", "") @@ -1141,6 +1144,16 @@ class CuraApplication(QtApplication): self._build_plate_model = BuildPlateModel(self) return self._build_plate_model + @pyqtSlot() + def exportUcp(self): + writer = self.getMeshFileHandler().getWriter("3MFWriter") + + if writer is None: + Logger.warning("3mf writer is not enabled") + return + + writer.exportUcp() + def getCuraSceneController(self, *args) -> CuraSceneController: if self._cura_scene_controller is None: self._cura_scene_controller = CuraSceneController.createCuraSceneController() diff --git a/plugins/3MFReader/SpecificSettingsModel.py b/plugins/3MFReader/SpecificSettingsModel.py new file mode 100644 index 0000000000..fd5719d6b3 --- /dev/null +++ b/plugins/3MFReader/SpecificSettingsModel.py @@ -0,0 +1,38 @@ +# Copyright (c) 2024 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from PyQt6.QtCore import Qt + +from UM.Settings.SettingDefinition import SettingDefinition +from UM.Qt.ListModel import ListModel + + +class SpecificSettingsModel(ListModel): + CategoryRole = Qt.ItemDataRole.UserRole + 1 + LabelRole = Qt.ItemDataRole.UserRole + 2 + ValueRole = Qt.ItemDataRole.UserRole + 3 + + def __init__(self, parent = None): + super().__init__(parent = parent) + self.addRoleName(self.CategoryRole, "category") + self.addRoleName(self.LabelRole, "label") + self.addRoleName(self.ValueRole, "value") + + self._i18n_catalog = None + + def addSettingsFromStack(self, stack, category, settings): + for setting, value in settings.items(): + unit = stack.getProperty(setting, "unit") + + setting_type = stack.getProperty(setting, "type") + if setting_type is not None: + # This is not very good looking, but will do for now + value = SettingDefinition.settingValueToString(setting_type, value) + " " + unit + else: + value = str(value) + + self.appendItem({ + "category": category, + "label": stack.getProperty(setting, "label"), + "value": value + }) diff --git a/plugins/3MFReader/ThreeMFReader.py b/plugins/3MFReader/ThreeMFReader.py index 85bdbfa551..ac94282136 100755 --- a/plugins/3MFReader/ThreeMFReader.py +++ b/plugins/3MFReader/ThreeMFReader.py @@ -41,7 +41,7 @@ class ThreeMFReader(MeshReader): MimeTypeDatabase.addMimeType( MimeType( - name = "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + name="application/vnd.ms-package.3dmanufacturing-3dmodel+xml", comment="3MF", suffixes=["3mf"] ) diff --git a/plugins/3MFReader/ThreeMFWorkspaceReader.py b/plugins/3MFReader/ThreeMFWorkspaceReader.py index b97cb34b01..25e2afa8bd 100755 --- a/plugins/3MFReader/ThreeMFWorkspaceReader.py +++ b/plugins/3MFReader/ThreeMFWorkspaceReader.py @@ -5,6 +5,7 @@ from configparser import ConfigParser import zipfile import os import json +import re from typing import cast, Dict, List, Optional, Tuple, Any, Set import xml.etree.ElementTree as ET @@ -57,6 +58,7 @@ _ignored_machine_network_metadata: Set[str] = { "is_abstract_machine" } +USER_SETTINGS_PATH = "Cura/user-settings.json" class ContainerInfo: def __init__(self, file_name: Optional[str], serialized: Optional[str], parser: Optional[ConfigParser]) -> None: @@ -141,10 +143,15 @@ class ThreeMFWorkspaceReader(WorkspaceReader): self._old_new_materials: Dict[str, str] = {} self._machine_info = None + self._load_profile = False + self._user_settings: Dict[str, Dict[str, Any]] = {} + def _clearState(self): self._id_mapping = {} self._old_new_materials = {} self._machine_info = None + self._load_profile = False + self._user_settings = {} def getNewId(self, old_id: str): """Get a unique name based on the old_id. This is different from directly calling the registry in that it caches results. @@ -228,11 +235,14 @@ class ThreeMFWorkspaceReader(WorkspaceReader): self._resolve_strategies = {k: None for k in resolve_strategy_keys} containers_found_dict = {k: False for k in resolve_strategy_keys} + # Check whether the file is a UCP, which changes some import options + is_ucp = USER_SETTINGS_PATH in cura_file_names + # # Read definition containers # machine_definition_id = None - updatable_machines = [] + updatable_machines = None if is_ucp else [] machine_definition_container_count = 0 extruder_definition_container_count = 0 definition_container_files = [name for name in cura_file_names if name.endswith(self._definition_container_suffix)] @@ -250,7 +260,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader): if definition_container_type == "machine": machine_definition_id = container_id machine_definition_containers = self._container_registry.findDefinitionContainers(id = machine_definition_id) - if machine_definition_containers: + if machine_definition_containers and updatable_machines is not None: updatable_machines = [machine for machine in self._container_registry.findContainerStacks(type = "machine") if machine.definition == machine_definition_containers[0]] machine_type = definition_container["name"] variant_type_name = definition_container.get("variants_name", variant_type_name) @@ -597,6 +607,37 @@ class ThreeMFWorkspaceReader(WorkspaceReader): package_metadata = self._parse_packages_metadata(archive) missing_package_metadata = self._filter_missing_package_metadata(package_metadata) + # Load the user specifically exported settings + self._dialog.exportedSettingModel.clear() + if is_ucp: + try: + self._user_settings = json.loads(archive.open("Cura/user-settings.json").read().decode("utf-8")) + any_extruder_stack = ExtruderManager.getInstance().getExtruderStack(0) + actual_global_stack = CuraApplication.getInstance().getGlobalContainerStack() + + for stack_name, settings in self._user_settings.items(): + if stack_name == 'global': + self._dialog.exportedSettingModel.addSettingsFromStack(actual_global_stack, i18n_catalog.i18nc("@label", "Global"), settings) + else: + extruder_match = re.fullmatch('extruder_([0-9]+)', stack_name) + if extruder_match is not None: + extruder_nr = int(extruder_match.group(1)) + self._dialog.exportedSettingModel.addSettingsFromStack(any_extruder_stack, + i18n_catalog.i18nc("@label", + "Extruder {0}", extruder_nr + 1), + settings) + except KeyError as e: + # If there is no user settings file, it's not a UCP, so notify user of failure. + Logger.log("w", "File %s is not a valid UCP.", file_name) + message = Message( + i18n_catalog.i18nc("@info:error Don't translate the XML tags or !", + "Project file {0} is corrupt: {1}.", + file_name, str(e)), + title=i18n_catalog.i18nc("@info:title", "Can't Open Project File"), + message_type=Message.MessageType.ERROR) + message.show() + return WorkspaceReader.PreReadResult.failed + # Show the dialog, informing the user what is about to happen. self._dialog.setMachineConflict(machine_conflict) self._dialog.setIsPrinterGroup(is_printer_group) @@ -617,8 +658,11 @@ class ThreeMFWorkspaceReader(WorkspaceReader): self._dialog.setVariantType(variant_type_name) self._dialog.setHasObjectsOnPlate(Application.getInstance().platformActivity) self._dialog.setMissingPackagesMetadata(missing_package_metadata) + self._dialog.setHasVisibleSelectSameProfileChanged(is_ucp) + self._dialog.setAllowCreatemachine(not is_ucp) self._dialog.show() + # Choosing the initially selected printer in MachineSelector is_networked_machine = False is_abstract_machine = False @@ -648,6 +692,8 @@ class ThreeMFWorkspaceReader(WorkspaceReader): self._dialog.setIsNetworkedMachine(is_networked_machine) self._dialog.setIsAbstractMachine(is_abstract_machine) self._dialog.setMachineName(machine_name) + self._dialog.updateCompatibleMachine() + self._dialog.setSelectSameProfileChecked(self._dialog.isCompatibleMachine) # Block until the dialog is closed. self._dialog.waitForClose() @@ -655,6 +701,8 @@ class ThreeMFWorkspaceReader(WorkspaceReader): if self._dialog.getResult() == {}: return WorkspaceReader.PreReadResult.cancelled + self._load_profile = not is_ucp or (self._dialog.selectSameProfileChecked and self._dialog.isCompatibleMachine) + self._resolve_strategies = self._dialog.getResult() # # There can be 3 resolve strategies coming from the dialog: @@ -690,16 +738,16 @@ class ThreeMFWorkspaceReader(WorkspaceReader): except EnvironmentError as e: message = Message(i18n_catalog.i18nc("@info:error Don't translate the XML tags or !", "Project file {0} is suddenly inaccessible: {1}.", file_name, str(e)), - title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"), - message_type = Message.MessageType.ERROR) + title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"), + message_type = Message.MessageType.ERROR) message.show() self.setWorkspaceName("") return [], {} except zipfile.BadZipFile as e: message = Message(i18n_catalog.i18nc("@info:error Don't translate the XML tags or !", "Project file {0} is corrupt: {1}.", file_name, str(e)), - title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"), - message_type = Message.MessageType.ERROR) + title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"), + message_type = Message.MessageType.ERROR) message.show() self.setWorkspaceName("") return [], {} @@ -761,9 +809,9 @@ class ThreeMFWorkspaceReader(WorkspaceReader): # Find the machine which will be overridden global_stacks = self._container_registry.findContainerStacks(id = self._dialog.getMachineToOverride(), type = "machine") if not global_stacks: - message = Message(i18n_catalog.i18nc("@info:error Don't translate the XML tag !", + message = Message(i18n_catalog.i18nc("@info:error Don't translate the XML tag !", "Project file {0} is made using profiles that are unknown to this version of UltiMaker Cura.", file_name), - message_type = Message.MessageType.ERROR) + message_type = Message.MessageType.ERROR) message.show() self.setWorkspaceName("") return [], {} @@ -777,84 +825,89 @@ class ThreeMFWorkspaceReader(WorkspaceReader): for stack in extruder_stacks: stack.setNextStack(global_stack, connect_signals = False) - Logger.log("d", "Workspace loading is checking definitions...") - # Get all the definition files & check if they exist. If not, add them. - definition_container_files = [name for name in cura_file_names if name.endswith(self._definition_container_suffix)] - for definition_container_file in definition_container_files: - container_id = self._stripFileToId(definition_container_file) + if self._load_profile: + Logger.log("d", "Workspace loading is checking definitions...") + # Get all the definition files & check if they exist. If not, add them. + definition_container_files = [name for name in cura_file_names if name.endswith(self._definition_container_suffix)] + for definition_container_file in definition_container_files: + container_id = self._stripFileToId(definition_container_file) - definitions = self._container_registry.findDefinitionContainersMetadata(id = container_id) - if not definitions: - definition_container = DefinitionContainer(container_id) - try: - definition_container.deserialize(archive.open(definition_container_file).read().decode("utf-8"), - file_name = definition_container_file) - except ContainerFormatError: - # We cannot just skip the definition file because everything else later will just break if the - # machine definition cannot be found. - Logger.logException("e", "Failed to deserialize definition file %s in project file %s", - definition_container_file, file_name) - definition_container = self._container_registry.findDefinitionContainers(id = "fdmprinter")[0] #Fall back to defaults. - self._container_registry.addContainer(definition_container) - Job.yieldThread() - QCoreApplication.processEvents() # Ensure that the GUI does not freeze. - - Logger.log("d", "Workspace loading is checking materials...") - # Get all the material files and check if they exist. If not, add them. - xml_material_profile = self._getXmlProfileClass() - if self._material_container_suffix is None: - self._material_container_suffix = ContainerRegistry.getMimeTypeForContainer(xml_material_profile).suffixes[0] - if xml_material_profile: - material_container_files = [name for name in cura_file_names if name.endswith(self._material_container_suffix)] - for material_container_file in material_container_files: - to_deserialize_material = False - container_id = self._stripFileToId(material_container_file) - need_new_name = False - materials = self._container_registry.findInstanceContainers(id = container_id) - - if not materials: - # No material found, deserialize this material later and add it - to_deserialize_material = True - else: - material_container = materials[0] - old_material_root_id = material_container.getMetaDataEntry("base_file") - if old_material_root_id is not None and not self._container_registry.isReadOnly(old_material_root_id): # Only create new materials if they are not read only. - to_deserialize_material = True - - if self._resolve_strategies["material"] == "override": - # Remove the old materials and then deserialize the one from the project - root_material_id = material_container.getMetaDataEntry("base_file") - application.getContainerRegistry().removeContainer(root_material_id) - elif self._resolve_strategies["material"] == "new": - # Note that we *must* deserialize it with a new ID, as multiple containers will be - # auto created & added. - container_id = self.getNewId(container_id) - self._old_new_materials[old_material_root_id] = container_id - need_new_name = True - - if to_deserialize_material: - material_container = xml_material_profile(container_id) + definitions = self._container_registry.findDefinitionContainersMetadata(id = container_id) + if not definitions: + definition_container = DefinitionContainer(container_id) try: - material_container.deserialize(archive.open(material_container_file).read().decode("utf-8"), - file_name = container_id + "." + self._material_container_suffix) + definition_container.deserialize(archive.open(definition_container_file).read().decode("utf-8"), + file_name = definition_container_file) except ContainerFormatError: - Logger.logException("e", "Failed to deserialize material file %s in project file %s", - material_container_file, file_name) - continue - if need_new_name: - new_name = ContainerRegistry.getInstance().uniqueName(material_container.getName()) - material_container.setName(new_name) - material_container.setDirty(True) - self._container_registry.addContainer(material_container) + # We cannot just skip the definition file because everything else later will just break if the + # machine definition cannot be found. + Logger.logException("e", "Failed to deserialize definition file %s in project file %s", + definition_container_file, file_name) + definition_container = self._container_registry.findDefinitionContainers(id = "fdmprinter")[0] #Fall back to defaults. + self._container_registry.addContainer(definition_container) Job.yieldThread() QCoreApplication.processEvents() # Ensure that the GUI does not freeze. - if global_stack: - # Handle quality changes if any - self._processQualityChanges(global_stack) + Logger.log("d", "Workspace loading is checking materials...") + # Get all the material files and check if they exist. If not, add them. + xml_material_profile = self._getXmlProfileClass() + if self._material_container_suffix is None: + self._material_container_suffix = ContainerRegistry.getMimeTypeForContainer(xml_material_profile).suffixes[0] + if xml_material_profile: + material_container_files = [name for name in cura_file_names if name.endswith(self._material_container_suffix)] + for material_container_file in material_container_files: + to_deserialize_material = False + container_id = self._stripFileToId(material_container_file) + need_new_name = False + materials = self._container_registry.findInstanceContainers(id = container_id) - # Prepare the machine - self._applyChangesToMachine(global_stack, extruder_stack_dict) + if not materials: + # No material found, deserialize this material later and add it + to_deserialize_material = True + else: + material_container = materials[0] + old_material_root_id = material_container.getMetaDataEntry("base_file") + if old_material_root_id is not None and not self._container_registry.isReadOnly(old_material_root_id): # Only create new materials if they are not read only. + to_deserialize_material = True + + if self._resolve_strategies["material"] == "override": + # Remove the old materials and then deserialize the one from the project + root_material_id = material_container.getMetaDataEntry("base_file") + application.getContainerRegistry().removeContainer(root_material_id) + elif self._resolve_strategies["material"] == "new": + # Note that we *must* deserialize it with a new ID, as multiple containers will be + # auto created & added. + container_id = self.getNewId(container_id) + self._old_new_materials[old_material_root_id] = container_id + need_new_name = True + + if to_deserialize_material: + material_container = xml_material_profile(container_id) + try: + material_container.deserialize(archive.open(material_container_file).read().decode("utf-8"), + file_name = container_id + "." + self._material_container_suffix) + except ContainerFormatError: + Logger.logException("e", "Failed to deserialize material file %s in project file %s", + material_container_file, file_name) + continue + if need_new_name: + new_name = ContainerRegistry.getInstance().uniqueName(material_container.getName()) + material_container.setName(new_name) + material_container.setDirty(True) + self._container_registry.addContainer(material_container) + Job.yieldThread() + QCoreApplication.processEvents() # Ensure that the GUI does not freeze. + + if global_stack: + if self._load_profile: + # Handle quality changes if any + self._processQualityChanges(global_stack) + + # Prepare the machine + self._applyChangesToMachine(global_stack, extruder_stack_dict) + else: + # Just clear the settings now, so that we can change the active machine without conflicts + self._clearMachineSettings(global_stack, extruder_stack_dict) Logger.log("d", "Workspace loading is notifying rest of the code of changes...") # Actually change the active machine. @@ -866,6 +919,10 @@ class ThreeMFWorkspaceReader(WorkspaceReader): # To solve this, we schedule _updateActiveMachine() for later so it will have the latest data. self._updateActiveMachine(global_stack) + if not self._load_profile: + # Now we have switched, apply the user settings + self._applyUserSettings(global_stack, extruder_stack_dict, self._user_settings) + # Load all the nodes / mesh data of the workspace nodes = self._3mf_mesh_reader.read(file_name) if nodes is None: @@ -1177,21 +1234,44 @@ class ThreeMFWorkspaceReader(WorkspaceReader): material_node = machine_node.variants[extruder_stack.variant.getName()].materials[root_material_id] extruder_stack.material = material_node.container - def _applyChangesToMachine(self, global_stack, extruder_stack_dict): - # Clear all first + def _clearMachineSettings(self, global_stack, extruder_stack_dict): self._clearStack(global_stack) for extruder_stack in extruder_stack_dict.values(): self._clearStack(extruder_stack) + self._quality_changes_to_apply = None + self._quality_type_to_apply = None + self._intent_category_to_apply = None + self._user_settings_to_apply = None + + def _applyUserSettings(self, global_stack, extruder_stack_dict, user_settings): + for stack_name, settings in user_settings.items(): + if stack_name == 'global': + ThreeMFWorkspaceReader._applyUserSettingsOnStack(global_stack, settings) + else: + extruder_match = re.fullmatch('extruder_([0-9]+)', stack_name) + if extruder_match is not None: + extruder_nr = extruder_match.group(1) + if extruder_nr in extruder_stack_dict: + ThreeMFWorkspaceReader._applyUserSettingsOnStack(extruder_stack_dict[extruder_nr], settings) + + @staticmethod + def _applyUserSettingsOnStack(stack, user_settings): + user_settings_container = stack.userChanges + + for setting_to_import, setting_value in user_settings.items(): + user_settings_container.setProperty(setting_to_import, 'value', setting_value) + + def _applyChangesToMachine(self, global_stack, extruder_stack_dict): + # Clear all first + self._clearMachineSettings(global_stack, extruder_stack_dict) + self._applyDefinitionChanges(global_stack, extruder_stack_dict) self._applyUserChanges(global_stack, extruder_stack_dict) self._applyVariants(global_stack, extruder_stack_dict) self._applyMaterials(global_stack, extruder_stack_dict) # prepare the quality to select - self._quality_changes_to_apply = None - self._quality_type_to_apply = None - self._intent_category_to_apply = None if self._machine_info.quality_changes_info is not None: self._quality_changes_to_apply = self._machine_info.quality_changes_info.name else: diff --git a/plugins/3MFReader/WorkspaceDialog.py b/plugins/3MFReader/WorkspaceDialog.py index 02e71f2185..c0ea950915 100644 --- a/plugins/3MFReader/WorkspaceDialog.py +++ b/plugins/3MFReader/WorkspaceDialog.py @@ -22,6 +22,8 @@ import time from cura.CuraApplication import CuraApplication +from .SpecificSettingsModel import SpecificSettingsModel + i18n_catalog = i18nCatalog("cura") @@ -71,6 +73,11 @@ class WorkspaceDialog(QObject): self._install_missing_package_dialog: Optional[QObject] = None self._is_abstract_machine = False self._is_networked_machine = False + self._is_compatible_machine = False + self._has_visible_select_same_profile = False + self._select_same_profile_checked = True + self._allow_create_machine = True + self._exported_settings_model = SpecificSettingsModel() machineConflictChanged = pyqtSignal() qualityChangesConflictChanged = pyqtSignal() @@ -94,6 +101,9 @@ class WorkspaceDialog(QObject): extrudersChanged = pyqtSignal() isPrinterGroupChanged = pyqtSignal() missingPackagesChanged = pyqtSignal() + isCompatibleMachineChanged = pyqtSignal() + hasVisibleSelectSameProfileChanged = pyqtSignal() + selectSameProfileCheckedChanged = pyqtSignal() @pyqtProperty(bool, notify = isPrinterGroupChanged) def isPrinterGroup(self) -> bool: @@ -292,6 +302,50 @@ class WorkspaceDialog(QObject): @pyqtSlot(str) def setMachineToOverride(self, machine_name: str) -> None: self._override_machine = machine_name + self.updateCompatibleMachine() + + def updateCompatibleMachine(self): + registry = ContainerRegistry.getInstance() + containers_expected = registry.findDefinitionContainers(name=self._machine_type) + containers_selected = registry.findContainerStacks(id=self._override_machine) + if len(containers_expected) == 1 and len(containers_selected) == 1: + new_compatible_machine = (containers_expected[0] == containers_selected[0].definition) + if new_compatible_machine != self._is_compatible_machine: + self._is_compatible_machine = new_compatible_machine + self.isCompatibleMachineChanged.emit() + + @pyqtProperty(bool, notify = isCompatibleMachineChanged) + def isCompatibleMachine(self) -> bool: + return self._is_compatible_machine + + def setHasVisibleSelectSameProfileChanged(self, has_visible_select_same_profile): + if has_visible_select_same_profile != self._has_visible_select_same_profile: + self._has_visible_select_same_profile = has_visible_select_same_profile + self.hasVisibleSelectSameProfileChanged.emit() + + @pyqtProperty(bool, notify = hasVisibleSelectSameProfileChanged) + def hasVisibleSelectSameProfile(self): + return self._has_visible_select_same_profile + + def setSelectSameProfileChecked(self, select_same_profile_checked): + if select_same_profile_checked != self._select_same_profile_checked: + self._select_same_profile_checked = select_same_profile_checked + self.selectSameProfileCheckedChanged.emit() + + @pyqtProperty(bool, notify = selectSameProfileCheckedChanged, fset = setSelectSameProfileChecked) + def selectSameProfileChecked(self): + return self._select_same_profile_checked + + def setAllowCreatemachine(self, allow_create_machine): + self._allow_create_machine = allow_create_machine + + @pyqtProperty(bool, constant = True) + def allowCreateMachine(self): + return self._allow_create_machine + + @pyqtProperty(QObject, constant = True) + def exportedSettingModel(self): + return self._exported_settings_model @pyqtSlot() def closeBackend(self) -> None: diff --git a/plugins/3MFReader/WorkspaceDialog.qml b/plugins/3MFReader/WorkspaceDialog.qml index cb1664a2c0..334317e0dc 100644 --- a/plugins/3MFReader/WorkspaceDialog.qml +++ b/plugins/3MFReader/WorkspaceDialog.qml @@ -6,7 +6,7 @@ import QtQuick.Controls 2.3 import QtQuick.Layouts 1.3 import QtQuick.Window 2.2 -import UM 1.5 as UM +import UM 1.6 as UM import Cura 1.1 as Cura UM.Dialog @@ -120,13 +120,17 @@ UM.Dialog minDropDownWidth: machineSelector.width - buttons: [ + Component + { + id: componentNewPrinter + Cura.SecondaryButton { id: createNewPrinter text: catalog.i18nc("@button", "Create new") fixedWidthMode: true width: parent.width - leftPadding * 1.5 + visible: manager.allowCreateMachine onClicked: { toggleContent() @@ -136,7 +140,9 @@ UM.Dialog manager.setIsNetworkedMachine(false) } } - ] + } + + buttons: manager.allowCreateMachine ? [componentNewPrinter.createObject()] : [] onSelectPrinter: function(machine) { @@ -165,26 +171,71 @@ UM.Dialog { leftLabelText: catalog.i18nc("@action:label", "Name") rightLabelText: manager.qualityName + visible: manager.isCompatibleMachine } WorkspaceRow { leftLabelText: catalog.i18nc("@action:label", "Intent") rightLabelText: manager.intentName + visible: manager.isCompatibleMachine } WorkspaceRow { leftLabelText: catalog.i18nc("@action:label", "Not in profile") rightLabelText: catalog.i18ncp("@action:label", "%1 override", "%1 overrides", manager.numUserSettings).arg(manager.numUserSettings) - visible: manager.numUserSettings != 0 + visible: manager.numUserSettings != 0 && manager.selectSameProfileChecked && manager.isCompatibleMachine } WorkspaceRow { leftLabelText: catalog.i18nc("@action:label", "Derivative from") rightLabelText: catalog.i18ncp("@action:label", "%1, %2 override", "%1, %2 overrides", manager.numSettingsOverridenByQualityChanges).arg(manager.qualityType).arg(manager.numSettingsOverridenByQualityChanges) - visible: manager.numSettingsOverridenByQualityChanges != 0 + visible: manager.numSettingsOverridenByQualityChanges != 0 && manager.selectSameProfileChecked && manager.isCompatibleMachine + } + + WorkspaceRow + { + leftLabelText: catalog.i18nc("@action:label", "Specific settings") + rightLabelText: catalog.i18ncp("@action:label", "%1 override", "%1 overrides", manager.exportedSettingModel.rowCount()).arg(manager.exportedSettingModel.rowCount()) + buttonText: tableViewSpecificSettings.shouldBeVisible ? catalog.i18nc("@action:button", "Hide settings") : catalog.i18nc("@action:button", "Show settings") + visible: !manager.selectSameProfileChecked || !manager.isCompatibleMachine + onButtonClicked: tableViewSpecificSettings.shouldBeVisible = !tableViewSpecificSettings.shouldBeVisible + } + + Cura.TableView + { + id: tableViewSpecificSettings + width: parent.width - parent.leftPadding - UM.Theme.getSize("default_margin").width + height: UM.Theme.getSize("card").height + visible: shouldBeVisible && (!manager.selectSameProfileChecked || !manager.isCompatibleMachine) + property bool shouldBeVisible: false + + columnHeaders: + [ + catalog.i18nc("@title:column", "Applies on"), + catalog.i18nc("@title:column", "Setting"), + catalog.i18nc("@title:column", "Value") + ] + + model: UM.TableModel + { + id: tableModel + headers: ["category", "label", "value"] + rows: manager.exportedSettingModel.items + } + } + + UM.CheckBox + { + text: catalog.i18nc("@action:checkbox", "Select the same profile") + onEnabledChanged: manager.selectSameProfileChecked = enabled + tooltip: enabled ? "" : catalog.i18nc("@tooltip", "You can use the same profile only if you have the same printer as the project was published with") + visible: manager.hasVisibleSelectSameProfile && manager.isCompatibleMachine + + checked: manager.selectSameProfileChecked + onCheckedChanged: manager.selectSameProfileChecked = checked } } diff --git a/plugins/3MFReader/WorkspaceRow.qml b/plugins/3MFReader/WorkspaceRow.qml index 8d9f1f25b3..855b8c18b0 100644 --- a/plugins/3MFReader/WorkspaceRow.qml +++ b/plugins/3MFReader/WorkspaceRow.qml @@ -9,26 +9,38 @@ import QtQuick.Window 2.2 import UM 1.5 as UM import Cura 1.1 as Cura -Row +RowLayout { + id: root + property alias leftLabelText: leftLabel.text property alias rightLabelText: rightLabel.text + property alias buttonText: button.text + signal buttonClicked width: parent.width - height: visible ? childrenRect.height : 0 UM.Label { id: leftLabel text: catalog.i18nc("@action:label", "Type") - width: Math.round(parent.width / 4) + Layout.preferredWidth: Math.round(parent.width / 4) wrapMode: Text.WordWrap } + UM.Label { id: rightLabel text: manager.machineType - width: Math.round(parent.width / 3) wrapMode: Text.WordWrap } + + Cura.TertiaryButton + { + id: button + visible: !text.isEmpty + Layout.maximumHeight: leftLabel.implicitHeight + Layout.fillWidth: true + onClicked: root.buttonClicked() + } } \ No newline at end of file diff --git a/plugins/3MFReader/WorkspaceSection.qml b/plugins/3MFReader/WorkspaceSection.qml index 0c94ab5d6a..63b5e89b41 100644 --- a/plugins/3MFReader/WorkspaceSection.qml +++ b/plugins/3MFReader/WorkspaceSection.qml @@ -5,7 +5,7 @@ import QtQuick 2.10 import QtQuick.Controls 2.3 -import UM 1.5 as UM +import UM 1.8 as UM Item @@ -80,34 +80,13 @@ Item sourceComponent: combobox } - MouseArea + UM.HelpIcon { - id: helpIconMouseArea anchors.right: parent.right anchors.verticalCenter: comboboxLabel.verticalCenter - width: childrenRect.width - height: childrenRect.height - hoverEnabled: true - UM.ColorImage - { - width: UM.Theme.getSize("section_icon").width - height: width - - visible: comboboxTooltipText != "" - source: UM.Theme.getIcon("Help") - color: UM.Theme.getColor("text") - - UM.ToolTip - { - text: comboboxTooltipText - visible: helpIconMouseArea.containsMouse - targetPoint: Qt.point(parent.x + Math.round(parent.width / 2), parent.y) - x: 0 - y: parent.y + parent.height + UM.Theme.getSize("default_margin").height - width: UM.Theme.getSize("tooltip").width - } - } + text: comboboxTooltipText + visible: comboboxTooltipText != "" } } diff --git a/plugins/3MFWriter/SettingExport.py b/plugins/3MFWriter/SettingExport.py new file mode 100644 index 0000000000..6702aa1f68 --- /dev/null +++ b/plugins/3MFWriter/SettingExport.py @@ -0,0 +1,38 @@ +# Copyright (c) 2024 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal + + +class SettingExport(QObject): + + def __init__(self, id, name, value, selectable): + super().__init__() + self.id = id + self._name = name + self._value = value + self._selected = selectable + self._selectable = selectable + + @pyqtProperty(str, constant=True) + def name(self): + return self._name + + @pyqtProperty(str, constant=True) + def value(self): + return self._value + + selectedChanged = pyqtSignal(bool) + + def setSelected(self, selected): + if selected != self._selected: + self._selected = selected + self.selectedChanged.emit(self._selected) + + @pyqtProperty(bool, fset = setSelected, notify = selectedChanged) + def selected(self): + return self._selected + + @pyqtProperty(bool, constant=True) + def selectable(self): + return self._selectable diff --git a/plugins/3MFWriter/SettingSelection.qml b/plugins/3MFWriter/SettingSelection.qml new file mode 100644 index 0000000000..478c2d393c --- /dev/null +++ b/plugins/3MFWriter/SettingSelection.qml @@ -0,0 +1,38 @@ +// Copyright (c) 2024 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.10 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 +import QtQuick.Window 2.2 + +import UM 1.8 as UM +import Cura 1.1 as Cura + +RowLayout +{ + id: settingSelection + + UM.CheckBox + { + text: modelData.name + Layout.preferredWidth: UM.Theme.getSize("setting").width + checked: modelData.selected + onClicked: modelData.selected = checked + enabled: modelData.selectable + } + + UM.Label + { + text: modelData.value + } + + UM.HelpIcon + { + UM.I18nCatalog { id: catalog; name: "cura" } + + text: catalog.i18nc("@tooltip", + "This setting can't be exported because it depends on the used printer capacities") + visible: !modelData.selectable + } +} diff --git a/plugins/3MFWriter/SettingsExportGroup.py b/plugins/3MFWriter/SettingsExportGroup.py new file mode 100644 index 0000000000..cc3fc7b4f5 --- /dev/null +++ b/plugins/3MFWriter/SettingsExportGroup.py @@ -0,0 +1,49 @@ +# Copyright (c) 2024 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from enum import IntEnum + +from PyQt6.QtCore import QObject, pyqtProperty, pyqtEnum + + +class SettingsExportGroup(QObject): + + @pyqtEnum + class Category(IntEnum): + Global = 0 + Extruder = 1 + Model = 2 + + def __init__(self, stack, name, category, settings, category_details = '', extruder_index = 0, extruder_color = ''): + super().__init__() + self.stack = stack + self._name = name + self._settings = settings + self._category = category + self._category_details = category_details + self._extruder_index = extruder_index + self._extruder_color = extruder_color + + @pyqtProperty(str, constant=True) + def name(self): + return self._name + + @pyqtProperty(list, constant=True) + def settings(self): + return self._settings + + @pyqtProperty(int, constant=True) + def category(self): + return self._category + + @pyqtProperty(str, constant=True) + def category_details(self): + return self._category_details + + @pyqtProperty(int, constant=True) + def extruder_index(self): + return self._extruder_index + + @pyqtProperty(str, constant=True) + def extruder_color(self): + return self._extruder_color diff --git a/plugins/3MFWriter/SettingsExportModel.py b/plugins/3MFWriter/SettingsExportModel.py new file mode 100644 index 0000000000..3b034236c8 --- /dev/null +++ b/plugins/3MFWriter/SettingsExportModel.py @@ -0,0 +1,130 @@ +# Copyright (c) 2024 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from dataclasses import asdict +from typing import Optional, cast, List, Dict, Pattern, Set + +from PyQt6.QtCore import QObject, pyqtProperty + +from UM.Settings.SettingDefinition import SettingDefinition +from UM.Settings.InstanceContainer import InstanceContainer +from UM.Settings.SettingFunction import SettingFunction + +from cura.CuraApplication import CuraApplication +from cura.Settings.ExtruderManager import ExtruderManager +from cura.Settings.GlobalStack import GlobalStack + +from .SettingsExportGroup import SettingsExportGroup +from .SettingExport import SettingExport + + +class SettingsExportModel(QObject): + + EXPORTABLE_SETTINGS = {'infill_sparse_density', + 'adhesion_type', + 'support_enable', + 'infill_pattern', + 'support_type', + 'support_structure', + 'support_angle', + 'support_infill_rate', + 'ironing_enabled', + 'fill_outline_gaps', + 'coasting_enable', + 'skin_monotonic', + 'z_seam_position', + 'infill_before_walls', + 'ironing_only_highest_layer', + 'xy_offset', + 'adaptive_layer_height_enabled', + 'brim_gap', + 'support_offset', + 'brim_outside_only', + 'magic_spiralize', + 'slicing_tolerance', + 'outer_inset_first', + 'magic_fuzzy_skin_outside_only', + 'conical_overhang_enabled', + 'min_infill_area', + 'small_hole_max_size', + 'magic_mesh_surface_mode', + 'carve_multiple_volumes', + 'meshfix_union_all_remove_holes', + 'support_tree_rest_preference', + 'small_feature_max_length', + 'draft_shield_enabled', + 'brim_smart_ordering', + 'ooze_shield_enabled', + 'bottom_skin_preshrink', + 'skin_edge_support_thickness', + 'alternate_carve_order', + 'top_skin_preshrink', + 'interlocking_enable'} + + def __init__(self, parent = None): + super().__init__(parent) + self._settings_groups = [] + + application = CuraApplication.getInstance() + + # Display global settings + global_stack = application.getGlobalContainerStack() + self._settings_groups.append(SettingsExportGroup(global_stack, + "Global settings", + SettingsExportGroup.Category.Global, + self._exportSettings(global_stack))) + + # Display per-extruder settings + extruders_stacks = ExtruderManager.getInstance().getUsedExtruderStacks() + for extruder_stack in extruders_stacks: + color = "" + if extruder_stack.material: + color = extruder_stack.material.getMetaDataEntry("color_code") + + self._settings_groups.append(SettingsExportGroup(extruder_stack, + "Extruder settings", + SettingsExportGroup.Category.Extruder, + self._exportSettings(extruder_stack), + extruder_index=extruder_stack.position, + extruder_color=color)) + + # Display per-model settings + scene_root = application.getController().getScene().getRoot() + for scene_node in scene_root.getChildren(): + per_model_stack = scene_node.callDecoration("getStack") + if per_model_stack is not None: + self._settings_groups.append(SettingsExportGroup(per_model_stack, + "Model settings", + SettingsExportGroup.Category.Model, + self._exportSettings(per_model_stack), + scene_node.getName())) + + @pyqtProperty(list, constant=True) + def settingsGroups(self) -> List[SettingsExportGroup]: + return self._settings_groups + + @staticmethod + def _exportSettings(settings_stack): + user_settings_container = settings_stack.userChanges + user_keys = user_settings_container.getAllKeys() + + settings_export = [] + + for setting_to_export in user_keys: + label = settings_stack.getProperty(setting_to_export, "label") + value = settings_stack.getProperty(setting_to_export, "value") + unit = settings_stack.getProperty(setting_to_export, "unit") + + setting_type = settings_stack.getProperty(setting_to_export, "type") + if setting_type is not None: + # This is not very good looking, but will do for now + value = f"{str(SettingDefinition.settingValueToString(setting_type, value))} {unit}" + else: + value = str(value) + + settings_export.append(SettingExport(setting_to_export, + label, + value, + setting_to_export in SettingsExportModel.EXPORTABLE_SETTINGS)) + + return settings_export diff --git a/plugins/3MFWriter/SettingsSelectionGroup.qml b/plugins/3MFWriter/SettingsSelectionGroup.qml new file mode 100644 index 0000000000..e77ba692bc --- /dev/null +++ b/plugins/3MFWriter/SettingsSelectionGroup.qml @@ -0,0 +1,87 @@ +// Copyright (c) 2024 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.10 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 +import QtQuick.Window 2.2 + +import UM 1.5 as UM +import Cura 1.1 as Cura +import ThreeMFWriter 1.0 as ThreeMFWriter + +ColumnLayout +{ + id: settingsGroup + spacing: UM.Theme.getSize("narrow_margin").width + + RowLayout + { + id: settingsGroupTitleRow + spacing: UM.Theme.getSize("default_margin").width + + Item + { + id: icon + height: UM.Theme.getSize("medium_button_icon").height + width: height + + UM.ColorImage + { + id: settingsMainImage + anchors.fill: parent + source: + { + switch(modelData.category) + { + case ThreeMFWriter.SettingsExportGroup.Global: + return UM.Theme.getIcon("Sliders") + case ThreeMFWriter.SettingsExportGroup.Model: + return UM.Theme.getIcon("View3D") + default: + return "" + } + } + + color: UM.Theme.getColor("text") + } + + Cura.ExtruderIcon + { + id: settingsExtruderIcon + anchors.fill: parent + visible: modelData.category === ThreeMFWriter.SettingsExportGroup.Extruder + text: (modelData.extruder_index + 1).toString() + font: UM.Theme.getFont("tiny_emphasis") + materialColor: modelData.extruder_color + } + } + + UM.Label + { + id: settingsTitle + text: modelData.name + (modelData.category_details ? ' (%1)'.arg(modelData.category_details) : '') + font: UM.Theme.getFont("default_bold") + } + } + + ListView + { + id: settingsExportList + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + spacing: 0 + model: modelData.settings + visible: modelData.settings.length > 0 + + delegate: SettingSelection { } + } + + UM.Label + { + UM.I18nCatalog { id: catalog; name: "cura" } + + text: catalog.i18nc("@label", "No specific value has been set") + visible: modelData.settings.length === 0 + } +} diff --git a/plugins/3MFWriter/ThreeMFWorkspaceWriter.py b/plugins/3MFWriter/ThreeMFWorkspaceWriter.py index e89af5c70a..2536f5dacb 100644 --- a/plugins/3MFWriter/ThreeMFWorkspaceWriter.py +++ b/plugins/3MFWriter/ThreeMFWorkspaceWriter.py @@ -1,9 +1,13 @@ # Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional + import configparser from io import StringIO +from threading import Lock import zipfile +from typing import Dict, Any from UM.Application import Application from UM.Logger import Logger @@ -13,15 +17,23 @@ from UM.Workspace.WorkspaceWriter import WorkspaceWriter from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") -from cura.Utils.Threading import call_on_qt_thread +from .ThreeMFWriter import ThreeMFWriter +from .SettingsExportModel import SettingsExportModel +from .SettingsExportGroup import SettingsExportGroup + +USER_SETTINGS_PATH = "Cura/user-settings.json" class ThreeMFWorkspaceWriter(WorkspaceWriter): def __init__(self): super().__init__() + self._ucp_model: Optional[SettingsExportModel] = None - @call_on_qt_thread - def write(self, stream, nodes, mode=WorkspaceWriter.OutputMode.BinaryMode): + def setExportModel(self, model: SettingsExportModel) -> None: + if self._ucp_model != model: + self._ucp_model = model + + def _write(self, stream, nodes, mode=WorkspaceWriter.OutputMode.BinaryMode): application = Application.getInstance() machine_manager = application.getMachineManager() @@ -34,20 +46,20 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter): global_stack = machine_manager.activeMachine if global_stack is None: - self.setInformation(catalog.i18nc("@error", "There is no workspace yet to write. Please add a printer first.")) + self.setInformation( + catalog.i18nc("@error", "There is no workspace yet to write. Please add a printer first.")) Logger.error("Tried to write a 3MF workspace before there was a global stack.") return False # Indicate that the 3mf mesh writer should not close the archive just yet (we still need to add stuff to it). mesh_writer.setStoreArchive(True) - if not mesh_writer.write(stream, nodes, mode): + if not mesh_writer.write(stream, nodes, mode, self._ucp_model): self.setInformation(mesh_writer.getInformation()) return False archive = mesh_writer.getArchive() if archive is None: # This happens if there was no mesh data to write. - archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED) - + archive = zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) try: # Add global container stack data to the archive. @@ -62,15 +74,21 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter): self._writeContainerToArchive(extruder_stack, archive) for container in extruder_stack.getContainers(): self._writeContainerToArchive(container, archive) + + # Write user settings data + if self._ucp_model is not None: + user_settings_data = self._getUserSettings(self._ucp_model) + ThreeMFWriter._storeMetadataJson(user_settings_data, archive, USER_SETTINGS_PATH) except PermissionError: self.setInformation(catalog.i18nc("@error:zip", "No permission to write the workspace here.")) Logger.error("No permission to write workspace to this stream.") return False # Write preferences to archive - original_preferences = Application.getInstance().getPreferences() #Copy only the preferences that we use to the workspace. + original_preferences = Application.getInstance().getPreferences() # Copy only the preferences that we use to the workspace. temp_preferences = Preferences() - for preference in {"general/visible_settings", "cura/active_mode", "cura/categories_expanded", "metadata/setting_version"}: + for preference in {"general/visible_settings", "cura/active_mode", "cura/categories_expanded", + "metadata/setting_version"}: temp_preferences.addPreference(preference, None) temp_preferences.setValue(preference, original_preferences.getValue(preference)) preferences_string = StringIO() @@ -81,7 +99,7 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter): # Save Cura version version_file = zipfile.ZipInfo("Cura/version.ini") - version_config_parser = configparser.ConfigParser(interpolation = None) + version_config_parser = configparser.ConfigParser(interpolation=None) version_config_parser.add_section("versions") version_config_parser.set("versions", "cura_version", application.getVersion()) version_config_parser.set("versions", "build_type", application.getBuildType()) @@ -101,11 +119,17 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter): return False except EnvironmentError as e: self.setInformation(catalog.i18nc("@error:zip", str(e))) - Logger.error("EnvironmentError when writing workspace to this stream: {err}".format(err = str(e))) + Logger.error("EnvironmentError when writing workspace to this stream: {err}".format(err=str(e))) return False mesh_writer.setStoreArchive(False) + return True + def write(self, stream, nodes, mode=WorkspaceWriter.OutputMode.BinaryMode): + success = self._write(stream, nodes, mode=WorkspaceWriter.OutputMode.BinaryMode) + self._ucp_model = None + return success + @staticmethod def _writePluginMetadataToArchive(archive: zipfile.ZipFile) -> None: file_name_template = "%s/plugin_metadata.json" @@ -165,4 +189,27 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter): archive.writestr(file_in_archive, serialized_data) except (FileNotFoundError, EnvironmentError): Logger.error("File became inaccessible while writing to it: {archive_filename}".format(archive_filename = archive.fp.name)) - return \ No newline at end of file + return + + @staticmethod + def _getUserSettings(model: SettingsExportModel) -> Dict[str, Dict[str, Any]]: + user_settings = {} + + for group in model.settingsGroups: + category = '' + if group.category == SettingsExportGroup.Category.Global: + category = 'global' + elif group.category == SettingsExportGroup.Category.Extruder: + category = f"extruder_{group.extruder_index}" + + if len(category) > 0: + settings_values = {} + stack = group.stack + + for setting in group.settings: + if setting.selected: + settings_values[setting.id] = stack.getProperty(setting.id, "value") + + user_settings[category] = settings_values + + return user_settings \ No newline at end of file diff --git a/plugins/3MFWriter/ThreeMFWriter.py b/plugins/3MFWriter/ThreeMFWriter.py index 23bc33659f..6fda1742f8 100644 --- a/plugins/3MFWriter/ThreeMFWriter.py +++ b/plugins/3MFWriter/ThreeMFWriter.py @@ -10,6 +10,9 @@ from UM.Math.Vector import Vector from UM.Logger import Logger from UM.Math.Matrix import Matrix from UM.Application import Application +from UM.OutputDevice import OutputDeviceError +from UM.Message import Message +from UM.Resources import Resources from UM.Scene.SceneNode import SceneNode from UM.Settings.ContainerRegistry import ContainerRegistry @@ -20,10 +23,11 @@ from cura.Utils.Threading import call_on_qt_thread from cura.Scene.CuraSceneNode import CuraSceneNode from cura.Snapshot import Snapshot -from PyQt6.QtCore import QBuffer +from PyQt6.QtCore import Qt, QBuffer +from PyQt6.QtGui import QImage, QPainter import pySavitar as Savitar - +from .UCPDialog import UCPDialog import numpy import datetime @@ -38,6 +42,9 @@ except ImportError: import zipfile import UM.Application +from .SettingsExportModel import SettingsExportModel +from .SettingsExportGroup import SettingsExportGroup + from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") @@ -85,7 +92,9 @@ class ThreeMFWriter(MeshWriter): self._store_archive = store_archive @staticmethod - def _convertUMNodeToSavitarNode(um_node, transformation=Matrix()): + def _convertUMNodeToSavitarNode(um_node, + transformation = Matrix(), + exported_settings: Optional[Dict[str, Set[str]]] = None): """Convenience function that converts an Uranium SceneNode object to a SavitarSceneNode :returns: Uranium Scene node. @@ -127,13 +136,22 @@ class ThreeMFWriter(MeshWriter): if stack is not None: changed_setting_keys = stack.getTop().getAllKeys() - # Ensure that we save the extruder used for this object in a multi-extrusion setup - if stack.getProperty("machine_extruder_count", "value") > 1: - changed_setting_keys.add("extruder_nr") + if exported_settings is None: + # Ensure that we save the extruder used for this object in a multi-extrusion setup + if stack.getProperty("machine_extruder_count", "value") > 1: + changed_setting_keys.add("extruder_nr") - # Get values for all changed settings & save them. - for key in changed_setting_keys: - savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value"))) + # Get values for all changed settings & save them. + for key in changed_setting_keys: + savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value"))) + else: + # We want to export only the specified settings + if um_node.getName() in exported_settings: + model_exported_settings = exported_settings[um_node.getName()] + + # Get values for all exported settings & save them. + for key in model_exported_settings: + savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value"))) if isinstance(um_node, CuraSceneNode): savitar_node.setSetting("cura:print_order", str(um_node.printOrder)) @@ -146,7 +164,8 @@ class ThreeMFWriter(MeshWriter): # only save the nodes on the active build plate if child_node.callDecoration("getBuildPlateNumber") != active_build_plate_nr: continue - savitar_child_node = ThreeMFWriter._convertUMNodeToSavitarNode(child_node) + savitar_child_node = ThreeMFWriter._convertUMNodeToSavitarNode(child_node, + exported_settings = exported_settings) if savitar_child_node is not None: savitar_node.addChild(savitar_child_node) @@ -155,7 +174,24 @@ class ThreeMFWriter(MeshWriter): def getArchive(self): return self._archive - def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode) -> bool: + def _addShareLogoToThumbnail(self, primary_image): + # Load the icon png image + icon_image = QImage(Resources.getPath(Resources.Images, "cura-share.png")) + + # Resize icon_image to be 1/4 of primary_image size + new_width = int(primary_image.width() / 4) + new_height = int(primary_image.height() / 4) + icon_image = icon_image.scaled(new_width, new_height, Qt.AspectRatioMode.KeepAspectRatio) + # Create a QPainter to draw on the image + painter = QPainter(primary_image) + + # Draw the icon in the top-left corner (adjust coordinates as needed) + icon_position = (10, 10) + painter.drawImage(icon_position[0], icon_position[1], icon_image) + + painter.end() + + def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode, export_settings_model = None) -> bool: self._archive = None # Reset archive archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED) try: @@ -179,6 +215,8 @@ class ThreeMFWriter(MeshWriter): # Attempt to add a thumbnail snapshot = self._createSnapshot() if snapshot: + if export_settings_model != None: + self._addShareLogoToThumbnail(snapshot) thumbnail_buffer = QBuffer() thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite) snapshot.save(thumbnail_buffer, "PNG") @@ -233,14 +271,19 @@ class ThreeMFWriter(MeshWriter): transformation_matrix.preMultiply(translation_matrix) root_node = UM.Application.Application.getInstance().getController().getScene().getRoot() + exported_model_settings = ThreeMFWriter._extractModelExportedSettings(export_settings_model) for node in nodes: if node == root_node: for root_child in node.getChildren(): - savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(root_child, transformation_matrix) + savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(root_child, + transformation_matrix, + exported_model_settings) if savitar_node: savitar_scene.addSceneNode(savitar_node) else: - savitar_node = self._convertUMNodeToSavitarNode(node, transformation_matrix) + savitar_node = self._convertUMNodeToSavitarNode(node, + transformation_matrix, + exported_model_settings) if savitar_node: savitar_scene.addSceneNode(savitar_node) @@ -396,3 +439,59 @@ class ThreeMFWriter(MeshWriter): parser = Savitar.ThreeMFParser() scene_string = parser.sceneToString(savitar_scene) return scene_string + + @staticmethod + def _extractModelExportedSettings(model: Optional[SettingsExportModel]) -> Dict[str, Set[str]]: + extra_settings = {} + + if model is not None: + for group in model.settingsGroups: + if group.category == SettingsExportGroup.Category.Model: + exported_model_settings = set() + + for exported_setting in group.settings: + if exported_setting.selected: + exported_model_settings.add(exported_setting.id) + + extra_settings[group.category_details] = exported_model_settings + + return extra_settings + + def exportUcp(self): + preferences = CuraApplication.getInstance().getPreferences() + if preferences.getValue("cura/dialog_on_ucp_project_save"): + self._config_dialog = UCPDialog() + self._config_dialog.show() + else: + application = CuraApplication.getInstance() + workspace_handler = application.getInstance().getWorkspaceFileHandler() + + # Set the model to the workspace writer + mesh_writer = workspace_handler.getWriter("3MFWriter") + mesh_writer.setExportModel(SettingsExportModel()) + + # Open file dialog and write the file + device = application.getOutputDeviceManager().getOutputDevice("local_file") + nodes = [application.getController().getScene().getRoot()] + + file_name = CuraApplication.getInstance().getPrintInformation().baseName + + try: + device.requestWrite( + nodes, + file_name, + ["application/vnd.ms-package.3dmanufacturing-3dmodel+xml"], + workspace_handler, + preferred_mimetype_list="application/vnd.ms-package.3dmanufacturing-3dmodel+xml" + ) + except OutputDeviceError.UserCanceledError: + self._onRejected() + except Exception as e: + message = Message( + catalog.i18nc("@info:error", "Unable to write to file: {0}", file_name), + title=catalog.i18nc("@info:title", "Error"), + message_type=Message.MessageType.ERROR + ) + message.show() + Logger.logException("e", "Unable to write to file %s: %s", file_name, e) + self._onRejected() diff --git a/plugins/3MFWriter/UCPDialog.py b/plugins/3MFWriter/UCPDialog.py new file mode 100644 index 0000000000..bedfb4d0da --- /dev/null +++ b/plugins/3MFWriter/UCPDialog.py @@ -0,0 +1,114 @@ +# Copyright (c) 2024 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import os + +from PyQt6.QtCore import pyqtSignal, QObject + +import UM +from UM.FlameProfiler import pyqtSlot +from UM.OutputDevice import OutputDeviceError +from UM.Workspace.WorkspaceWriter import WorkspaceWriter +from UM.i18n import i18nCatalog +from UM.Logger import Logger +from UM.Message import Message + +from cura.CuraApplication import CuraApplication + +from .SettingsExportModel import SettingsExportModel + +i18n_catalog = i18nCatalog("cura") + + +class UCPDialog(QObject): + finished = pyqtSignal(bool) + + def __init__(self, parent = None) -> None: + super().__init__(parent) + + plugin_path = os.path.dirname(__file__) + dialog_path = os.path.join(plugin_path, 'UCPDialog.qml') + self._model = SettingsExportModel() + self._view = CuraApplication.getInstance().createQmlComponent( + dialog_path, + { + "manager": self, + "settingsExportModel": self._model + } + ) + self._view.accepted.connect(self._onAccepted) + self._view.rejected.connect(self._onRejected) + self._finished = False + self._accepted = False + + def show(self) -> None: + self._finished = False + self._accepted = False + self._view.show() + + def getModel(self) -> SettingsExportModel: + return self._model + + @pyqtSlot() + def notifyClosed(self): + self._onFinished() + + def save3mf(self): + application = CuraApplication.getInstance() + workspace_handler = application.getInstance().getWorkspaceFileHandler() + + # Set the model to the workspace writer + mesh_writer = workspace_handler.getWriter("3MFWriter") + mesh_writer.setExportModel(self._model) + + # Open file dialog and write the file + device = application.getOutputDeviceManager().getOutputDevice("local_file") + nodes = [application.getController().getScene().getRoot()] + + device.writeError.connect(lambda: self._onRejected()) + device.writeSuccess.connect(lambda: self._onSuccess()) + device.writeFinished.connect(lambda: self._onFinished()) + + file_name = CuraApplication.getInstance().getPrintInformation().baseName + + try: + device.requestWrite( + nodes, + file_name, + ["application/vnd.ms-package.3dmanufacturing-3dmodel+xml"], + workspace_handler, + preferred_mimetype_list="application/vnd.ms-package.3dmanufacturing-3dmodel+xml" + ) + except OutputDeviceError.UserCanceledError: + self._onRejected() + except Exception as e: + message = Message( + i18n_catalog.i18nc("@info:error", "Unable to write to file: {0}", file_name), + title=i18n_catalog.i18nc("@info:title", "Error"), + message_type=Message.MessageType.ERROR + ) + message.show() + Logger.logException("e", "Unable to write to file %s: %s", file_name, e) + self._onRejected() + + def _onAccepted(self): + self.save3mf() + + def _onRejected(self): + self._onFinished() + + def _onSuccess(self): + self._accepted = True + self._onFinished() + + def _onFinished(self): + # Make sure we don't send the finished signal twice, whatever happens + if self._finished: + return + self._finished = True + + # Reset the model to the workspace writer + mesh_writer = CuraApplication.getInstance().getInstance().getWorkspaceFileHandler().getWriter("3MFWriter") + mesh_writer.setExportModel(None) + + self.finished.emit(self._accepted) diff --git a/plugins/3MFWriter/UCPDialog.qml b/plugins/3MFWriter/UCPDialog.qml new file mode 100644 index 0000000000..3a0e6bf842 --- /dev/null +++ b/plugins/3MFWriter/UCPDialog.qml @@ -0,0 +1,125 @@ +// Copyright (c) 2024 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.10 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 +import QtQuick.Window 2.2 + +import UM 1.5 as UM +import Cura 1.1 as Cura + +UM.Dialog +{ + id: exportDialog + title: catalog.i18nc("@title:window", "Export Universal Cura Project") + + margin: UM.Theme.getSize("default_margin").width + minimumWidth: UM.Theme.getSize("modal_window_minimum").width + minimumHeight: UM.Theme.getSize("modal_window_minimum").height + + backgroundColor: UM.Theme.getColor("detail_background") + property bool dontShowAgain: false + + function storeDontShowAgain() + { + UM.Preferences.setValue("cura/dialog_on_ucp_project_save", !dontShowAgainCheckbox.checked) + UM.Preferences.setValue("cura/asked_dialog_on_ucp_project_save", false) + } + + onVisibleChanged: + { + if(visible && UM.Preferences.getValue("cura/asked_dialog_on_ucp_project_save")) + { + dontShowAgain = !UM.Preferences.getValue("cura/dialog_on_ucp_project_save") + } + } + + headerComponent: Rectangle + { + height: childrenRect.height + 2 * UM.Theme.getSize("default_margin").height + color: UM.Theme.getColor("main_background") + + ColumnLayout + { + id: headerColumn + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: UM.Theme.getSize("default_margin").height + anchors.leftMargin: UM.Theme.getSize("default_margin").width + anchors.rightMargin: anchors.leftMargin + + UM.Label + { + id: titleLabel + text: catalog.i18nc("@action:title", "Summary - Universal Cura Project") + font: UM.Theme.getFont("large") + } + + UM.Label + { + id: descriptionLabel + text: catalog.i18nc("@action:description", "When exporting a Universal Cura Project, all the models present on the build plate will be included with their current position, orientation and scale. You can also select which per-extruder or per-model settings should be included to ensure a proper printing of the batch, even on different printers.") + font: UM.Theme.getFont("default") + wrapMode: Text.Wrap + Layout.maximumWidth: headerColumn.width + } + } + } + + Rectangle + { + anchors.fill: parent + color: UM.Theme.getColor("main_background") + + UM.I18nCatalog { id: catalog; name: "cura" } + + ListView + { + id: settingsExportList + anchors.fill: parent + anchors.margins: UM.Theme.getSize("default_margin").width + spacing: UM.Theme.getSize("thick_margin").height + model: settingsExportModel.settingsGroups + clip: true + + ScrollBar.vertical: UM.ScrollBar { id: verticalScrollBar } + + delegate: SettingsSelectionGroup { Layout.margins: 0 } + } + } + leftButtons: + [ + UM.CheckBox + { + id: dontShowAgainCheckbox + text: catalog.i18nc("@action:label", "Don't show project summary on save again") + checked: dontShowAgain + } + ] + rightButtons: + [ + Cura.TertiaryButton + { + text: catalog.i18nc("@action:button", "Cancel") + onClicked: reject() + }, + Cura.PrimaryButton + { + text: catalog.i18nc("@action:button", "Save project") + onClicked: accept() + } + ] + + buttonSpacing: UM.Theme.getSize("wide_margin").width + + onClosing: + { + storeDontShowAgain() + manager.notifyClosed() + } + onRejected: storeDontShowAgain() + onAccepted: storeDontShowAgain() +} diff --git a/plugins/3MFWriter/__init__.py b/plugins/3MFWriter/__init__.py index eb8a596afe..0b2976c386 100644 --- a/plugins/3MFWriter/__init__.py +++ b/plugins/3MFWriter/__init__.py @@ -2,9 +2,12 @@ # Uranium is released under the terms of the LGPLv3 or higher. import sys +from PyQt6.QtQml import qmlRegisterType + from UM.Logger import Logger try: from . import ThreeMFWriter + from .SettingsExportGroup import SettingsExportGroup threemf_writer_was_imported = True except ImportError: Logger.log("w", "Could not import ThreeMFWriter; libSavitar may be missing") @@ -23,20 +26,24 @@ def getMetaData(): if threemf_writer_was_imported: metaData["mesh_writer"] = { - "output": [{ - "extension": "3mf", - "description": i18n_catalog.i18nc("@item:inlistbox", "3MF file"), - "mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", - "mode": ThreeMFWriter.ThreeMFWriter.OutputMode.BinaryMode - }] + "output": [ + { + "extension": "3mf", + "description": i18n_catalog.i18nc("@item:inlistbox", "3MF file"), + "mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + "mode": ThreeMFWriter.ThreeMFWriter.OutputMode.BinaryMode + } + ] } metaData["workspace_writer"] = { - "output": [{ - "extension": workspace_extension, - "description": i18n_catalog.i18nc("@item:inlistbox", "Cura Project 3MF file"), - "mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", - "mode": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter.OutputMode.BinaryMode - }] + "output": [ + { + "extension": workspace_extension, + "description": i18n_catalog.i18nc("@item:inlistbox", "Cura Project 3MF file"), + "mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + "mode": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter.OutputMode.BinaryMode + } + ] } return metaData @@ -44,6 +51,8 @@ def getMetaData(): def register(app): if "3MFWriter.ThreeMFWriter" in sys.modules: + qmlRegisterType(SettingsExportGroup, "ThreeMFWriter", 1, 0, "SettingsExportGroup") + return {"mesh_writer": ThreeMFWriter.ThreeMFWriter(), "workspace_writer": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter()} else: diff --git a/plugins/3MFWriter/plugin.json b/plugins/3MFWriter/plugin.json index b59d4ef8e1..254384dc25 100644 --- a/plugins/3MFWriter/plugin.json +++ b/plugins/3MFWriter/plugin.json @@ -2,7 +2,7 @@ "name": "3MF Writer", "author": "Ultimaker B.V.", "version": "1.0.1", - "description": "Provides support for writing 3MF files.", + "description": "Provides support for writing 3MF and UCP files.", "api": 8, "i18n-catalog": "cura" } diff --git a/resources/images/cura-share.png b/resources/images/cura-share.png new file mode 100644 index 0000000000..60de85194c Binary files /dev/null and b/resources/images/cura-share.png differ diff --git a/resources/qml/Dialogs/WorkspaceSummaryDialog.qml b/resources/qml/Dialogs/WorkspaceSummaryDialog.qml index a174959807..1eca2f395c 100644 --- a/resources/qml/Dialogs/WorkspaceSummaryDialog.qml +++ b/resources/qml/Dialogs/WorkspaceSummaryDialog.qml @@ -25,7 +25,7 @@ UM.Dialog function storeDontShowAgain() { UM.Preferences.setValue("cura/dialog_on_project_save", !dontShowAgainCheckbox.checked) - UM.Preferences.setValue("asked_dialog_on_project_save", true) + UM.Preferences.setValue("cura/asked_dialog_on_project_save", true) } onClosing: storeDontShowAgain() diff --git a/resources/qml/ExtruderIcon.qml b/resources/qml/ExtruderIcon.qml index 3231d924ee..bd15df7848 100644 --- a/resources/qml/ExtruderIcon.qml +++ b/resources/qml/ExtruderIcon.qml @@ -15,6 +15,7 @@ Item property int iconSize: UM.Theme.getSize("extruder_icon").width property string iconVariant: "medium" property alias font: extruderNumberText.font + property alias text: extruderNumberText.text implicitWidth: iconSize implicitHeight: iconSize diff --git a/resources/qml/Menus/FileMenu.qml b/resources/qml/Menus/FileMenu.qml index 0884053ef3..67edcc5962 100644 --- a/resources/qml/Menus/FileMenu.qml +++ b/resources/qml/Menus/FileMenu.qml @@ -47,8 +47,12 @@ Cura.Menu enabled: UM.WorkspaceFileHandler.enabled && saveProjectMenu.model.count == 1 onTriggered: { - var args = { "filter_by_machine": false, "file_type": "workspace", "preferred_mimetypes": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml" }; - if(UM.Preferences.getValue("cura/dialog_on_project_save")) + const args = { + "filter_by_machine": false, + "file_type": "workspace", + "preferred_mimetypes": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + }; + if (UM.Preferences.getValue("cura/dialog_on_project_save")) { saveWorkspaceDialog.args = args saveWorkspaceDialog.open() @@ -70,6 +74,14 @@ Cura.Menu enabled: UM.WorkspaceFileHandler.enabled } + Cura.MenuItem + { + id: saveUCPMenu + text: catalog.i18nc("@title:menu menubar:file", "&Save Universal Cura Project...") + enabled: UM.WorkspaceFileHandler.enabled && CuraApplication.getPackageManager().allEnabledPackages.includes("3MFWriter") + onTriggered: CuraApplication.exportUcp() + } + Cura.MenuSeparator { } Cura.MenuItem @@ -78,8 +90,11 @@ Cura.Menu text: catalog.i18nc("@title:menu menubar:file", "&Export...") onTriggered: { - var localDeviceId = "local_file" - UM.OutputDeviceManager.requestWriteToDevice(localDeviceId, PrintInformation.jobName, { "filter_by_machine": false, "preferred_mimetypes": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml"}) + const args = { + "filter_by_machine": false, + "preferred_mimetypes": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + }; + UM.OutputDeviceManager.requestWriteToDevice("local_file", PrintInformation.jobName, args); } } @@ -89,7 +104,13 @@ Cura.Menu text: catalog.i18nc("@action:inmenu menubar:file", "Export Selection...") enabled: UM.Selection.hasSelection icon.name: "document-save-as" - onTriggered: UM.OutputDeviceManager.requestWriteSelectionToDevice("local_file", PrintInformation.jobName, { "filter_by_machine": false, "preferred_mimetypes": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml"}) + onTriggered: { + const args = { + "filter_by_machine": false, + "preferred_mimetypes": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + }; + UM.OutputDeviceManager.requestWriteSelectionToDevice("local_file", PrintInformation.jobName, args); + } } Cura.MenuSeparator { } diff --git a/resources/qml/Preferences/GeneralPage.qml b/resources/qml/Preferences/GeneralPage.qml index 69607a3f6b..d87ecac0b3 100644 --- a/resources/qml/Preferences/GeneralPage.qml +++ b/resources/qml/Preferences/GeneralPage.qml @@ -101,6 +101,7 @@ UM.PreferencesPage centerOnSelectCheckbox.checked = boolCheck(UM.Preferences.getValue("view/center_on_select")) UM.Preferences.resetPreference("view/invert_zoom"); invertZoomCheckbox.checked = boolCheck(UM.Preferences.getValue("view/invert_zoom")) + UM.Preferences.resetPreference("view/navigation_style"); UM.Preferences.resetPreference("view/zoom_to_mouse"); zoomToMouseCheckbox.checked = boolCheck(UM.Preferences.getValue("view/zoom_to_mouse")) //UM.Preferences.resetPreference("view/top_layer_count"); @@ -614,6 +615,55 @@ UM.PreferencesPage } } + UM.TooltipArea + { + width: childrenRect.width + height: childrenRect.height + text: catalog.i18nc("@info:tooltip", "What type of camera navigation should be used?") + Column + { + spacing: UM.Theme.getSize("narrow_margin").height + + UM.Label + { + text: catalog.i18nc("@window:text", "Camera navigation:") + } + ListModel + { + id: navigationStylesList + Component.onCompleted: + { + append({ text: catalog.i18n("Cura"), code: "cura" }) + append({ text: catalog.i18n("FreeCAD trackpad"), code: "freecad_trackpad" }) + } + } + + Cura.ComboBox + { + id: cameraNavigationComboBox + + model: navigationStylesList + textRole: "text" + width: UM.Theme.getSize("combobox").width + height: UM.Theme.getSize("combobox").height + + currentIndex: + { + var code = UM.Preferences.getValue("view/navigation_style"); + for(var i = 0; i < comboBoxList.count; ++i) + { + if(model.get(i).code == code) + { + return i + } + } + return 0 + } + onActivated: UM.Preferences.setValue("view/navigation_style", model.get(index).code) + } + } + } + Item { //: Spacer @@ -735,6 +785,20 @@ UM.PreferencesPage } } + UM.TooltipArea + { + width: childrenRect.width + height: childrenRect.height + text: catalog.i18nc("@info:tooltip", "Should a summary be shown when saving a UCP project file?") + + UM.CheckBox + { + text: catalog.i18nc("@option:check", "Show summary dialog when saving a UCP project") + checked: boolCheck(UM.Preferences.getValue("cura/dialog_on_ucp_project_save")) + onCheckedChanged: UM.Preferences.setValue("cura/dialog_on_ucp_project_save", checked) + } + } + UM.TooltipArea { width: childrenRect.width