diff --git a/cura/PrinterOutputDevice.py b/cura/PrinterOutputDevice.py index 7eb37037b9..6e7305b27b 100644 --- a/cura/PrinterOutputDevice.py +++ b/cura/PrinterOutputDevice.py @@ -51,7 +51,7 @@ class PrinterOutputDevice(QObject, OutputDevice): self._camera_active = False - def requestWrite(self, nodes, file_name = None, filter_by_machine = False): + def requestWrite(self, nodes, file_name = None, filter_by_machine = False, file_handler = None): raise NotImplementedError("requestWrite needs to be implemented") ## Signals diff --git a/cura/Settings/ExtruderManager.py b/cura/Settings/ExtruderManager.py index a3eef51009..4d4f66652e 100644 --- a/cura/Settings/ExtruderManager.py +++ b/cura/Settings/ExtruderManager.py @@ -152,6 +152,18 @@ class ExtruderManager(QObject): if changed: self.extrudersChanged.emit(machine_id) + def registerExtruder(self, extruder_train, machine_id): + changed = False + + if machine_id not in self._extruder_trains: + self._extruder_trains[machine_id] = {} + changed = True + if extruder_train: + self._extruder_trains[machine_id][extruder_train.getMetaDataEntry("position")] = extruder_train + changed = True + if changed: + self.extrudersChanged.emit(machine_id) + ## Creates a container stack for an extruder train. # # The container stack has an extruder definition at the bottom, which is diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 88ff9e829b..a04a7398ad 100644 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -277,7 +277,7 @@ class MachineManager(QObject): def _onInstanceContainersChanged(self, container): container_type = container.getMetaDataEntry("type") - + if container_type == "material": self.activeMaterialChanged.emit() elif container_type == "variant": diff --git a/plugins/3MFReader/ThreeMFReader.py b/plugins/3MFReader/ThreeMFReader.py index 2aa719018d..3fee1adcb8 100644 --- a/plugins/3MFReader/ThreeMFReader.py +++ b/plugins/3MFReader/ThreeMFReader.py @@ -84,20 +84,20 @@ class ThreeMFReader(MeshReader): definition = QualityManager.getInstance().getParentMachineDefinition(global_container_stack.getBottom()) node.callDecoration("getStack").getTop().setDefinition(definition) - setting_container = node.callDecoration("getStack").getTop() - for setting in xml_settings: - setting_key = setting.get("key") - setting_value = setting.text + setting_container = node.callDecoration("getStack").getTop() + for setting in xml_settings: + setting_key = setting.get("key") + setting_value = setting.text - # Extruder_nr is a special case. - if setting_key == "extruder_nr": - extruder_stack = ExtruderManager.getInstance().getExtruderStack(int(setting_value)) - if extruder_stack: - node.callDecoration("setActiveExtruder", extruder_stack.getId()) - else: - Logger.log("w", "Unable to find extruder in position %s", setting_value) - continue - setting_container.setProperty(setting_key,"value", setting_value) + # Extruder_nr is a special case. + if setting_key == "extruder_nr": + extruder_stack = ExtruderManager.getInstance().getExtruderStack(int(setting_value)) + if extruder_stack: + node.callDecoration("setActiveExtruder", extruder_stack.getId()) + else: + Logger.log("w", "Unable to find extruder in position %s", setting_value) + continue + setting_container.setProperty(setting_key,"value", setting_value) if len(node.getChildren()) > 0: group_decorator = GroupDecorator() diff --git a/plugins/3MFReader/ThreeMFWorkspaceReader.py b/plugins/3MFReader/ThreeMFWorkspaceReader.py new file mode 100644 index 0000000000..79f2399cf7 --- /dev/null +++ b/plugins/3MFReader/ThreeMFWorkspaceReader.py @@ -0,0 +1,396 @@ +from UM.Workspace.WorkspaceReader import WorkspaceReader +from UM.Application import Application + +from UM.Logger import Logger +from UM.i18n import i18nCatalog +from UM.Settings.ContainerStack import ContainerStack +from UM.Settings.DefinitionContainer import DefinitionContainer +from UM.Settings.InstanceContainer import InstanceContainer +from UM.Settings.ContainerRegistry import ContainerRegistry +from UM.MimeTypeDatabase import MimeTypeDatabase + +from UM.Preferences import Preferences +from .WorkspaceDialog import WorkspaceDialog + +from cura.Settings.ExtruderManager import ExtruderManager + +import zipfile +import io +import configparser + +i18n_catalog = i18nCatalog("cura") + + +## Base implementation for reading 3MF workspace files. +class ThreeMFWorkspaceReader(WorkspaceReader): + def __init__(self): + super().__init__() + self._supported_extensions = [".3mf"] + self._dialog = WorkspaceDialog() + self._3mf_mesh_reader = None + self._container_registry = ContainerRegistry.getInstance() + self._definition_container_suffix = ContainerRegistry.getMimeTypeForContainer(DefinitionContainer).preferredSuffix + self._material_container_suffix = None # We have to wait until all other plugins are loaded before we can set it + self._instance_container_suffix = ContainerRegistry.getMimeTypeForContainer(InstanceContainer).preferredSuffix + self._container_stack_suffix = ContainerRegistry.getMimeTypeForContainer(ContainerStack).preferredSuffix + + self._resolve_strategies = {} + + self._id_mapping = {} + + ## Get a unique name based on the old_id. This is different from directly calling the registry in that it caches results. + # This has nothing to do with speed, but with getting consistent new naming for instances & objects. + def getNewId(self, old_id): + if old_id not in self._id_mapping: + self._id_mapping[old_id] = self._container_registry.uniqueName(old_id) + return self._id_mapping[old_id] + + def preRead(self, file_name): + self._3mf_mesh_reader = Application.getInstance().getMeshFileHandler().getReaderForFile(file_name) + if self._3mf_mesh_reader and self._3mf_mesh_reader.preRead(file_name) == WorkspaceReader.PreReadResult.accepted: + pass + else: + Logger.log("w", "Could not find reader that was able to read the scene data for 3MF workspace") + return WorkspaceReader.PreReadResult.failed + + # Check if there are any conflicts, so we can ask the user. + archive = zipfile.ZipFile(file_name, "r") + cura_file_names = [name for name in archive.namelist() if name.startswith("Cura/")] + container_stack_files = [name for name in cura_file_names if name.endswith(self._container_stack_suffix)] + self._resolve_strategies = {"machine": None, "quality_changes": None, "material": None} + machine_conflict = False + quality_changes_conflict = False + for container_stack_file in container_stack_files: + container_id = self._stripFileToId(container_stack_file) + stacks = self._container_registry.findContainerStacks(id=container_id) + if stacks: + # Check if there are any changes at all in any of the container stacks. + id_list = self._getContainerIdListFromSerialized(archive.open(container_stack_file).read().decode("utf-8")) + for index, container_id in enumerate(id_list): + if stacks[0].getContainer(index).getId() != container_id: + machine_conflict = True + break + + material_conflict = False + xml_material_profile = self._getXmlProfileClass() + if self._material_container_suffix is None: + self._material_container_suffix = ContainerRegistry.getMimeTypeForContainer(xml_material_profile).preferredSuffix + 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: + container_id = self._stripFileToId(material_container_file) + materials = self._container_registry.findInstanceContainers(id=container_id) + if materials and not materials[0].isReadOnly(): # Only non readonly materials can be in conflict + material_conflict = True + + # Check if any quality_changes instance container is in conflict. + instance_container_files = [name for name in cura_file_names if name.endswith(self._instance_container_suffix)] + for instance_container_file in instance_container_files: + container_id = self._stripFileToId(instance_container_file) + instance_container = InstanceContainer(container_id) + + # Deserialize InstanceContainer by converting read data from bytes to string + instance_container.deserialize(archive.open(instance_container_file).read().decode("utf-8")) + container_type = instance_container.getMetaDataEntry("type") + if container_type == "quality_changes": + # Check if quality changes already exists. + quality_changes = self._container_registry.findInstanceContainers(id = container_id) + if quality_changes: + # Check if there really is a conflict by comparing the values + if quality_changes[0] != instance_container: + quality_changes_conflict = True + break + try: + archive.open("Cura/preferences.cfg") + except KeyError: + # If there is no preferences file, it's not a workspace, so notify user of failure. + Logger.log("w", "File %s is not a valid workspace.", file_name) + return WorkspaceReader.PreReadResult.failed + + if machine_conflict or quality_changes_conflict or material_conflict: + # There is a conflict; User should choose to either update the existing data, add everything as new data or abort + self._dialog.setMachineConflict(machine_conflict) + self._dialog.setQualityChangesConflict(quality_changes_conflict) + self._dialog.setMaterialConflict(material_conflict) + self._dialog.show() + + # Block until the dialog is closed. + self._dialog.waitForClose() + + if self._dialog.getResult() == {}: + return WorkspaceReader.PreReadResult.cancelled + + self._resolve_strategies = self._dialog.getResult() + + return WorkspaceReader.PreReadResult.accepted + + def read(self, file_name): + # Load all the nodes / meshdata of the workspace + nodes = self._3mf_mesh_reader.read(file_name) + if nodes is None: + nodes = [] + + archive = zipfile.ZipFile(file_name, "r") + + cura_file_names = [name for name in archive.namelist() if name.startswith("Cura/")] + + # Create a shadow copy of the preferences (we don't want all of the preferences, but we do want to re-use its + # parsing code. + temp_preferences = Preferences() + temp_preferences.readFromFile(io.TextIOWrapper(archive.open("Cura/preferences.cfg"))) # We need to wrap it, else the archive parser breaks. + + # Copy a number of settings from the temp preferences to the global + global_preferences = Preferences.getInstance() + global_preferences.setValue("general/visible_settings", temp_preferences.getValue("general/visible_settings")) + global_preferences.setValue("cura/categories_expanded", temp_preferences.getValue("cura/categories_expanded")) + Application.getInstance().expandedCategoriesChanged.emit() # Notify the GUI of the change + + self._id_mapping = {} + + # We don't add containers right away, but wait right until right before the stack serialization. + # We do this so that if something goes wrong, it's easier to clean up. + containers_to_add = [] + + # TODO: For the moment we use pretty naive existence checking. If the ID is the same, we assume in quite a few + # TODO: cases that the container loaded is the same (most notable in materials & definitions). + # TODO: It might be possible that we need to add smarter checking in the future. + 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.findDefinitionContainers(id=container_id) + if not definitions: + definition_container = DefinitionContainer(container_id) + definition_container.deserialize(archive.open(definition_container_file).read().decode("utf-8")) + self._container_registry.addContainer(definition_container) + + Logger.log("d", "Workspace loading is checking materials...") + material_containers = [] + # 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: + container_id = self._stripFileToId(material_container_file) + materials = self._container_registry.findInstanceContainers(id=container_id) + if not materials: + material_container = xml_material_profile(container_id) + material_container.deserialize(archive.open(material_container_file).read().decode("utf-8")) + containers_to_add.append(material_container) + else: + if not materials[0].isReadOnly(): # Only create new materials if they are not read only. + if self._resolve_strategies["material"] == "override": + materials[0].deserialize(archive.open(material_container_file).read().decode("utf-8")) + elif self._resolve_strategies["material"] == "new": + # Note that we *must* deserialize it with a new ID, as multiple containers will be + # auto created & added. + material_container = xml_material_profile(self.getNewId(container_id)) + material_container.deserialize(archive.open(material_container_file).read().decode("utf-8")) + containers_to_add.append(material_container) + material_containers.append(material_container) + + Logger.log("d", "Workspace loading is checking instance containers...") + # Get quality_changes and user profiles saved in the workspace + instance_container_files = [name for name in cura_file_names if name.endswith(self._instance_container_suffix)] + user_instance_containers = [] + quality_changes_instance_containers = [] + for instance_container_file in instance_container_files: + container_id = self._stripFileToId(instance_container_file) + instance_container = InstanceContainer(container_id) + + # Deserialize InstanceContainer by converting read data from bytes to string + instance_container.deserialize(archive.open(instance_container_file).read().decode("utf-8")) + container_type = instance_container.getMetaDataEntry("type") + if container_type == "user": + # Check if quality changes already exists. + user_containers = self._container_registry.findInstanceContainers(id=container_id) + if not user_containers: + containers_to_add.append(instance_container) + else: + if self._resolve_strategies["machine"] == "override": + user_containers[0].deserialize(archive.open(instance_container_file).read().decode("utf-8")) + elif self._resolve_strategies["machine"] == "new": + # The machine is going to get a spiffy new name, so ensure that the id's of user settings match. + extruder_id = instance_container.getMetaDataEntry("extruder", None) + if extruder_id: + new_id = self.getNewId(extruder_id) + "_current_settings" + instance_container._id = new_id + instance_container.setName(new_id) + instance_container.setMetaDataEntry("extruder", self.getNewId(extruder_id)) + containers_to_add.append(instance_container) + + machine_id = instance_container.getMetaDataEntry("machine", None) + if machine_id: + new_id = self.getNewId(machine_id) + "_current_settings" + instance_container._id = new_id + instance_container.setName(new_id) + instance_container.setMetaDataEntry("machine", self.getNewId(machine_id)) + containers_to_add.append(instance_container) + user_instance_containers.append(instance_container) + elif container_type == "quality_changes": + # Check if quality changes already exists. + quality_changes = self._container_registry.findInstanceContainers(id = container_id) + if not quality_changes: + containers_to_add.append(instance_container) + else: + if self._resolve_strategies["quality_changes"] == "override": + quality_changes[0].deserialize(archive.open(instance_container_file).read().decode("utf-8")) + elif self._resolve_strategies["quality_changes"] is None: + # The ID already exists, but nothing in the values changed, so do nothing. + pass + quality_changes_instance_containers.append(instance_container) + else: + continue + + # Add all the containers right before we try to add / serialize the stack + for container in containers_to_add: + self._container_registry.addContainer(container) + + # Get the stack(s) saved in the workspace. + Logger.log("d", "Workspace loading is checking stacks containers...") + container_stack_files = [name for name in cura_file_names if name.endswith(self._container_stack_suffix)] + global_stack = None + extruder_stacks = [] + container_stacks_added = [] + try: + for container_stack_file in container_stack_files: + container_id = self._stripFileToId(container_stack_file) + + # Check if a stack by this ID already exists; + container_stacks = self._container_registry.findContainerStacks(id=container_id) + if container_stacks: + stack = container_stacks[0] + if self._resolve_strategies["machine"] == "override": + container_stacks[0].deserialize(archive.open(container_stack_file).read().decode("utf-8")) + elif self._resolve_strategies["machine"] == "new": + new_id = self.getNewId(container_id) + stack = ContainerStack(new_id) + stack.deserialize(archive.open(container_stack_file).read().decode("utf-8")) + + # Ensure a unique ID and name + stack._id = new_id + + # Extruder stacks are "bound" to a machine. If we add the machine as a new one, the id of the + # bound machine also needs to change. + if stack.getMetaDataEntry("machine", None): + stack.setMetaDataEntry("machine", self.getNewId(stack.getMetaDataEntry("machine"))) + + if stack.getMetaDataEntry("type") != "extruder_train": + # Only machines need a new name, stacks may be non-unique + stack.setName(self._container_registry.uniqueName(stack.getName())) + container_stacks_added.append(stack) + self._container_registry.addContainer(stack) + else: + Logger.log("w", "Resolve strategy of %s for machine is not supported", self._resolve_strategies["machine"]) + else: + stack = ContainerStack(container_id) + # Deserialize stack by converting read data from bytes to string + stack.deserialize(archive.open(container_stack_file).read().decode("utf-8")) + container_stacks_added.append(stack) + self._container_registry.addContainer(stack) + + if stack.getMetaDataEntry("type") == "extruder_train": + extruder_stacks.append(stack) + else: + global_stack = stack + except: + Logger.log("W", "We failed to serialize the stack. Trying to clean up.") + # Something went really wrong. Try to remove any data that we added. + for container in containers_to_add: + self._container_registry.getInstance().removeContainer(container.getId()) + + for container in container_stacks_added: + self._container_registry.getInstance().removeContainer(container.getId()) + + return None + + if self._resolve_strategies["machine"] == "new": + # A new machine was made, but it was serialized with the wrong user container. Fix that now. + for container in user_instance_containers: + extruder_id = container.getMetaDataEntry("extruder", None) + if extruder_id: + for extruder in extruder_stacks: + if extruder.getId() == extruder_id: + extruder.replaceContainer(0, container) + continue + machine_id = container.getMetaDataEntry("machine", None) + if machine_id: + if global_stack.getId() == machine_id: + global_stack.replaceContainer(0, container) + continue + + if self._resolve_strategies["quality_changes"] == "new": + # Quality changes needs to get a new ID, added to registry and to the right stacks + for container in quality_changes_instance_containers: + old_id = container.getId() + container.setName(self._container_registry.uniqueName(container.getName())) + # We're not really supposed to change the ID in normal cases, but this is an exception. + container._id = self.getNewId(container.getId()) + + # The container was not added yet, as it didn't have an unique ID. It does now, so add it. + self._container_registry.addContainer(container) + + # Replace the quality changes container + old_container = global_stack.findContainer({"type": "quality_changes"}) + if old_container.getId() == old_id: + quality_changes_index = global_stack.getContainerIndex(old_container) + global_stack.replaceContainer(quality_changes_index, container) + continue + + for stack in extruder_stacks: + old_container = stack.findContainer({"type": "quality_changes"}) + if old_container.getId() == old_id: + quality_changes_index = stack.getContainerIndex(old_container) + stack.replaceContainer(quality_changes_index, container) + + if self._resolve_strategies["material"] == "new": + for material in material_containers: + old_material = global_stack.findContainer({"type": "material"}) + if old_material.getId() in self._id_mapping: + material_index = global_stack.getContainerIndex(old_material) + global_stack.replaceContainer(material_index, material) + continue + + for stack in extruder_stacks: + old_material = stack.findContainer({"type": "material"}) + if old_material.getId() in self._id_mapping: + material_index = stack.getContainerIndex(old_material) + stack.replaceContainer(material_index, material) + continue + + for stack in extruder_stacks: + ExtruderManager.getInstance().registerExtruder(stack, global_stack.getId()) + else: + # Machine has no extruders, but it needs to be registered with the extruder manager. + ExtruderManager.getInstance().registerExtruder(None, global_stack.getId()) + + Logger.log("d", "Workspace loading is notifying rest of the code of changes...") + # Notify everything/one that is to notify about changes. + for container in global_stack.getContainers(): + global_stack.containersChanged.emit(container) + + for stack in extruder_stacks: + stack.setNextStack(global_stack) + for container in stack.getContainers(): + stack.containersChanged.emit(container) + + # Actually change the active machine. + Application.getInstance().setGlobalContainerStack(global_stack) + return nodes + + def _stripFileToId(self, file): + return file.replace("Cura/", "").split(".")[0] + + def _getXmlProfileClass(self): + return self._container_registry.getContainerForMimeType(MimeTypeDatabase.getMimeType("application/x-ultimaker-material-profile")) + + ## Get the list of ID's of all containers in a container stack by partially parsing it's serialized data. + def _getContainerIdListFromSerialized(self, serialized): + parser = configparser.ConfigParser(interpolation=None, empty_lines_in_values=False) + parser.read_string(serialized) + container_string = parser["general"].get("containers", "") + container_list = container_string.split(",") + return [container_id for container_id in container_list if container_id != ""] diff --git a/plugins/3MFReader/WorkspaceDialog.py b/plugins/3MFReader/WorkspaceDialog.py new file mode 100644 index 0000000000..bf9dce8264 --- /dev/null +++ b/plugins/3MFReader/WorkspaceDialog.py @@ -0,0 +1,134 @@ +from PyQt5.QtCore import Qt, QUrl, pyqtSignal, pyqtSlot, QObject, pyqtProperty, QCoreApplication +from PyQt5.QtQml import QQmlComponent, QQmlContext +from UM.PluginRegistry import PluginRegistry +from UM.Application import Application +from UM.Logger import Logger + +import os +import threading +import time + +class WorkspaceDialog(QObject): + showDialogSignal = pyqtSignal() + + def __init__(self, parent = None): + super().__init__(parent) + self._component = None + self._context = None + self._view = None + self._qml_url = "WorkspaceDialog.qml" + self._lock = threading.Lock() + self._default_strategy = "override" + self._result = {"machine": self._default_strategy, + "quality_changes": self._default_strategy, + "material": self._default_strategy} + self._visible = False + self.showDialogSignal.connect(self.__show) + + self._has_quality_changes_conflict = False + self._has_machine_conflict = False + self._has_material_conflict = False + + machineConflictChanged = pyqtSignal() + qualityChangesConflictChanged = pyqtSignal() + materialConflictChanged = pyqtSignal() + + @pyqtProperty(bool, notify = machineConflictChanged) + def machineConflict(self): + return self._has_machine_conflict + + @pyqtProperty(bool, notify=qualityChangesConflictChanged) + def qualityChangesConflict(self): + return self._has_quality_changes_conflict + + @pyqtProperty(bool, notify=materialConflictChanged) + def materialConflict(self): + return self._has_material_conflict + + @pyqtSlot(str, str) + def setResolveStrategy(self, key, strategy): + if key in self._result: + self._result[key] = strategy + + def setMaterialConflict(self, material_conflict): + self._has_material_conflict = material_conflict + self.materialConflictChanged.emit() + + def setMachineConflict(self, machine_conflict): + self._has_machine_conflict = machine_conflict + self.machineConflictChanged.emit() + + def setQualityChangesConflict(self, quality_changes_conflict): + self._has_quality_changes_conflict = quality_changes_conflict + self.qualityChangesConflictChanged.emit() + + def getResult(self): + if "machine" in self._result and not self._has_machine_conflict: + self._result["machine"] = None + if "quality_changes" in self._result and not self._has_quality_changes_conflict: + self._result["quality_changes"] = None + if "material" in self._result and not self._has_material_conflict: + self._result["material"] = None + return self._result + + def _createViewFromQML(self): + path = QUrl.fromLocalFile(os.path.join(PluginRegistry.getInstance().getPluginPath("3MFReader"), self._qml_url)) + self._component = QQmlComponent(Application.getInstance()._engine, path) + self._context = QQmlContext(Application.getInstance()._engine.rootContext()) + self._context.setContextProperty("manager", self) + self._view = self._component.create(self._context) + if self._view is None: + Logger.log("c", "QQmlComponent status %s", self._component.status()) + Logger.log("c", "QQmlComponent error string %s", self._component.errorString()) + + def show(self): + # Emit signal so the right thread actually shows the view. + if threading.current_thread() != threading.main_thread(): + self._lock.acquire() + # Reset the result + self._result = {"machine": self._default_strategy, + "quality_changes": self._default_strategy, + "material": self._default_strategy} + self._visible = True + self.showDialogSignal.emit() + + @pyqtSlot() + ## Used to notify the dialog so the lock can be released. + def notifyClosed(self): + if self._result is None: + self._result = {} + self._lock.release() + + def hide(self): + self._visible = False + self._lock.release() + self._view.hide() + + @pyqtSlot() + def onOkButtonClicked(self): + self._view.hide() + self.hide() + + @pyqtSlot() + def onCancelButtonClicked(self): + self._view.hide() + self.hide() + self._result = {} + + ## Block thread until the dialog is closed. + def waitForClose(self): + if self._visible: + if threading.current_thread() != threading.main_thread(): + self._lock.acquire() + self._lock.release() + else: + # If this is not run from a separate thread, we need to ensure that the events are still processed. + while self._visible: + time.sleep(1 / 50) + QCoreApplication.processEvents() # Ensure that the GUI does not freeze. + + def __show(self): + if self._view is None: + self._createViewFromQML() + if self._view: + self._view.show() diff --git a/plugins/3MFReader/WorkspaceDialog.qml b/plugins/3MFReader/WorkspaceDialog.qml new file mode 100644 index 0000000000..cdefd9a4b0 --- /dev/null +++ b/plugins/3MFReader/WorkspaceDialog.qml @@ -0,0 +1,172 @@ +// Copyright (c) 2016 Ultimaker B.V. +// Cura is released under the terms of the AGPLv3 or higher. + +import QtQuick 2.1 +import QtQuick.Controls 1.1 +import QtQuick.Layouts 1.1 +import QtQuick.Window 2.1 + +import UM 1.1 as UM + +UM.Dialog +{ + title: catalog.i18nc("@title:window", "Import workspace conflict") + + width: 350 * Screen.devicePixelRatio; + minimumWidth: 350 * Screen.devicePixelRatio; + maximumWidth: 350 * Screen.devicePixelRatio; + + height: 250 * Screen.devicePixelRatio; + minimumHeight: 250 * Screen.devicePixelRatio; + maximumHeight: 250 * Screen.devicePixelRatio; + + onClosing: manager.notifyClosed() + onVisibleChanged: + { + if(visible) + { + machineResolveComboBox.currentIndex = 0 + qualityChangesResolveComboBox.currentIndex = 0 + materialConflictComboBox.currentIndex = 0 + } + } + Item + { + anchors.fill: parent + + UM.I18nCatalog + { + id: catalog; + name: "cura"; + } + + ListModel + { + id: resolveStrategiesModel + // Instead of directly adding the list elements, we add them afterwards. + // This is because it's impossible to use setting function results to be bound to listElement properties directly. + // See http://stackoverflow.com/questions/7659442/listelement-fields-as-properties + Component.onCompleted: + { + append({"key": "override", "label": catalog.i18nc("@action:ComboBox option", "Override existing")}); + append({"key": "new", "label": catalog.i18nc("@action:ComboBox option", "Create new")}); + } + } + + Column + { + anchors.fill: parent + Label + { + id: infoLabel + width: parent.width + text: catalog.i18nc("@action:label", "Cura detected a number of conflicts while importing the workspace. How would you like to resolve these?") + wrapMode: Text.Wrap + height: 50 + } + UM.TooltipArea + { + id: machineResolveTooltip + width: parent.width + height: visible ? 25 : 0 + text: catalog.i18nc("@info:tooltip", "How should the conflict in the machine be resolved?") + visible: manager.machineConflict + Row + { + width: parent.width + height: childrenRect.height + Label + { + text: catalog.i18nc("@action:label","Machine") + width: 150 + } + + ComboBox + { + model: resolveStrategiesModel + textRole: "label" + id: machineResolveComboBox + onActivated: + { + manager.setResolveStrategy("machine", resolveStrategiesModel.get(index).key) + } + } + } + } + UM.TooltipArea + { + id: qualityChangesResolveTooltip + width: parent.width + height: visible ? 25 : 0 + text: catalog.i18nc("@info:tooltip", "How should the conflict in the profile be resolved?") + visible: manager.qualityChangesConflict + Row + { + width: parent.width + height: childrenRect.height + Label + { + text: catalog.i18nc("@action:label","Profile") + width: 150 + } + + ComboBox + { + model: resolveStrategiesModel + textRole: "label" + id: qualityChangesResolveComboBox + onActivated: + { + manager.setResolveStrategy("quality_changes", resolveStrategiesModel.get(index).key) + } + } + } + } + UM.TooltipArea + { + id: materialResolveTooltip + width: parent.width + height: visible ? 25 : 0 + text: catalog.i18nc("@info:tooltip", "How should the conflict in the material(s) be resolved?") + visible: manager.materialConflict + Row + { + width: parent.width + height: childrenRect.height + Label + { + text: catalog.i18nc("@action:label","Material") + width: 150 + } + + ComboBox + { + model: resolveStrategiesModel + textRole: "label" + id: materialResolveComboBox + onActivated: + { + manager.setResolveStrategy("material", resolveStrategiesModel.get(index).key) + } + } + } + } + } + } + rightButtons: [ + Button + { + id: ok_button + text: catalog.i18nc("@action:button","OK"); + onClicked: { manager.onOkButtonClicked() } + enabled: true + }, + Button + { + id: cancel_button + text: catalog.i18nc("@action:button","Cancel"); + onClicked: { manager.onCancelButtonClicked() } + enabled: true + } + ] +} \ No newline at end of file diff --git a/plugins/3MFReader/__init__.py b/plugins/3MFReader/__init__.py index 42b1794160..a2af30211d 100644 --- a/plugins/3MFReader/__init__.py +++ b/plugins/3MFReader/__init__.py @@ -2,10 +2,11 @@ # Cura is released under the terms of the AGPLv3 or higher. from . import ThreeMFReader - +from . import ThreeMFWorkspaceReader from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") + def getMetaData(): return { "plugin": { @@ -20,8 +21,17 @@ def getMetaData(): "extension": "3mf", "description": catalog.i18nc("@item:inlistbox", "3MF File") } + ], + "workspace_reader": + [ + { + "extension": "3mf", + "description": catalog.i18nc("@item:inlistbox", "3MF File") + } ] } + def register(app): - return { "mesh_reader": ThreeMFReader.ThreeMFReader() } + return {"mesh_reader": ThreeMFReader.ThreeMFReader(), + "workspace_reader": ThreeMFWorkspaceReader.ThreeMFWorkspaceReader()} diff --git a/plugins/3MFWriter/ThreeMFWorkspaceWriter.py b/plugins/3MFWriter/ThreeMFWorkspaceWriter.py new file mode 100644 index 0000000000..cafc18858f --- /dev/null +++ b/plugins/3MFWriter/ThreeMFWorkspaceWriter.py @@ -0,0 +1,78 @@ +from UM.Workspace.WorkspaceWriter import WorkspaceWriter +from UM.Application import Application +from UM.Preferences import Preferences +from UM.Settings.ContainerRegistry import ContainerRegistry +from cura.Settings.ExtruderManager import ExtruderManager +import zipfile +from io import StringIO + + +class ThreeMFWorkspaceWriter(WorkspaceWriter): + def __init__(self): + super().__init__() + + def write(self, stream, nodes, mode=WorkspaceWriter.OutputMode.BinaryMode): + mesh_writer = Application.getInstance().getMeshFileHandler().getWriter("3MFWriter") + + if not mesh_writer: # We need to have the 3mf mesh writer, otherwise we can't save the entire workspace + 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) + mesh_writer.write(stream, nodes, mode) + + 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) + + global_container_stack = Application.getInstance().getGlobalContainerStack() + + # Add global container stack data to the archive. + self._writeContainerToArchive(global_container_stack, archive) + + # Also write all containers in the stack to the file + for container in global_container_stack.getContainers(): + self._writeContainerToArchive(container, archive) + + # Check if the machine has extruders and save all that data as well. + for extruder_stack in ExtruderManager.getInstance().getMachineExtruders(global_container_stack.getId()): + self._writeContainerToArchive(extruder_stack, archive) + for container in extruder_stack.getContainers(): + self._writeContainerToArchive(container, archive) + + # Write preferences to archive + preferences_file = zipfile.ZipInfo("Cura/preferences.cfg") + preferences_string = StringIO() + Preferences.getInstance().writeToFile(preferences_string) + archive.writestr(preferences_file, preferences_string.getvalue()) + + # Close the archive & reset states. + archive.close() + mesh_writer.setStoreArchive(False) + return True + + ## Helper function that writes ContainerStacks, InstanceContainers and DefinitionContainers to the archive. + # \param container That follows the \type{ContainerInterface} to archive. + # \param archive The archive to write to. + @staticmethod + def _writeContainerToArchive(container, archive): + if type(container) == type(ContainerRegistry.getInstance().getEmptyInstanceContainer()): + return # Empty file, do nothing. + + file_suffix = ContainerRegistry.getMimeTypeForContainer(type(container)).preferredSuffix + + # Some containers have a base file, which should then be the file to use. + if "base_file" in container.getMetaData(): + base_file = container.getMetaDataEntry("base_file") + container = ContainerRegistry.getInstance().findContainers(id = base_file)[0] + + file_name = "Cura/%s.%s" % (container.getId(), file_suffix) + + if file_name in archive.namelist(): + return # File was already saved, no need to do it again. Uranium guarantees unique ID's, so this should hold. + + file_in_archive = zipfile.ZipInfo(file_name) + # For some reason we have to set the compress type of each file as well (it doesn't keep the type of the entire archive) + file_in_archive.compress_type = zipfile.ZIP_DEFLATED + + archive.writestr(file_in_archive, container.serialize()) diff --git a/plugins/3MFWriter/ThreeMFWriter.py b/plugins/3MFWriter/ThreeMFWriter.py new file mode 100644 index 0000000000..d86b119276 --- /dev/null +++ b/plugins/3MFWriter/ThreeMFWriter.py @@ -0,0 +1,201 @@ +# Copyright (c) 2015 Ultimaker B.V. +# Uranium is released under the terms of the AGPLv3 or higher. + +from UM.Mesh.MeshWriter import MeshWriter +from UM.Math.Vector import Vector +from UM.Logger import Logger +from UM.Math.Matrix import Matrix +from UM.Settings.SettingRelation import RelationType + +try: + import xml.etree.cElementTree as ET +except ImportError: + Logger.log("w", "Unable to load cElementTree, switching to slower version") + import xml.etree.ElementTree as ET + +import zipfile +import UM.Application + + +class ThreeMFWriter(MeshWriter): + def __init__(self): + super().__init__() + self._namespaces = { + "3mf": "http://schemas.microsoft.com/3dmanufacturing/core/2015/02", + "content-types": "http://schemas.openxmlformats.org/package/2006/content-types", + "relationships": "http://schemas.openxmlformats.org/package/2006/relationships", + "cura": "http://software.ultimaker.com/xml/cura/3mf/2015/10" + } + + self._unit_matrix_string = self._convertMatrixToString(Matrix()) + self._archive = None + self._store_archive = False + + def _convertMatrixToString(self, matrix): + result = "" + result += str(matrix._data[0,0]) + " " + result += str(matrix._data[1,0]) + " " + result += str(matrix._data[2,0]) + " " + result += str(matrix._data[0,1]) + " " + result += str(matrix._data[1,1]) + " " + result += str(matrix._data[2,1]) + " " + result += str(matrix._data[0,2]) + " " + result += str(matrix._data[1,2]) + " " + result += str(matrix._data[2,2]) + " " + result += str(matrix._data[0,3]) + " " + result += str(matrix._data[1,3]) + " " + result += str(matrix._data[2,3]) + " " + return result + + ## Should we store the archive + # Note that if this is true, the archive will not be closed. + # The object that set this parameter is then responsible for closing it correctly! + def setStoreArchive(self, store_archive): + self._store_archive = store_archive + + def getArchive(self): + return self._archive + + def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode): + self._archive = None # Reset archive + archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED) + try: + model_file = zipfile.ZipInfo("3D/3dmodel.model") + # Because zipfile is stupid and ignores archive-level compression settings when writing with ZipInfo. + model_file.compress_type = zipfile.ZIP_DEFLATED + + # Create content types file + content_types_file = zipfile.ZipInfo("[Content_Types].xml") + content_types_file.compress_type = zipfile.ZIP_DEFLATED + content_types = ET.Element("Types", xmlns = self._namespaces["content-types"]) + rels_type = ET.SubElement(content_types, "Default", Extension = "rels", ContentType = "application/vnd.openxmlformats-package.relationships+xml") + model_type = ET.SubElement(content_types, "Default", Extension = "model", ContentType = "application/vnd.ms-package.3dmanufacturing-3dmodel+xml") + + # Create _rels/.rels file + relations_file = zipfile.ZipInfo("_rels/.rels") + relations_file.compress_type = zipfile.ZIP_DEFLATED + relations_element = ET.Element("Relationships", xmlns = self._namespaces["relationships"]) + model_relation_element = ET.SubElement(relations_element, "Relationship", Target = "/3D/3dmodel.model", Id = "rel0", Type = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel") + + model = ET.Element("model", unit = "millimeter", xmlns = self._namespaces["3mf"]) + resources = ET.SubElement(model, "resources") + build = ET.SubElement(model, "build") + + added_nodes = [] + index = 0 # Ensure index always exists (even if there are no nodes to write) + # Write all nodes with meshData to the file as objects inside the resource tag + for index, n in enumerate(MeshWriter._meshNodes(nodes)): + added_nodes.append(n) # Save the nodes that have mesh data + object = ET.SubElement(resources, "object", id = str(index+1), type = "model") + mesh = ET.SubElement(object, "mesh") + + mesh_data = n.getMeshData() + vertices = ET.SubElement(mesh, "vertices") + verts = mesh_data.getVertices() + + if verts is None: + Logger.log("d", "3mf writer can't write nodes without mesh data. Skipping this node.") + continue # No mesh data, nothing to do. + if mesh_data.hasIndices(): + for face in mesh_data.getIndices(): + v1 = verts[face[0]] + v2 = verts[face[1]] + v3 = verts[face[2]] + xml_vertex1 = ET.SubElement(vertices, "vertex", x = str(v1[0]), y = str(v1[1]), z = str(v1[2])) + xml_vertex2 = ET.SubElement(vertices, "vertex", x = str(v2[0]), y = str(v2[1]), z = str(v2[2])) + xml_vertex3 = ET.SubElement(vertices, "vertex", x = str(v3[0]), y = str(v3[1]), z = str(v3[2])) + + triangles = ET.SubElement(mesh, "triangles") + for face in mesh_data.getIndices(): + triangle = ET.SubElement(triangles, "triangle", v1 = str(face[0]) , v2 = str(face[1]), v3 = str(face[2])) + else: + triangles = ET.SubElement(mesh, "triangles") + for idx, vert in enumerate(verts): + xml_vertex = ET.SubElement(vertices, "vertex", x = str(vert[0]), y = str(vert[1]), z = str(vert[2])) + + # If we have no faces defined, assume that every three subsequent vertices form a face. + if idx % 3 == 0: + triangle = ET.SubElement(triangles, "triangle", v1 = str(idx), v2 = str(idx + 1), v3 = str(idx + 2)) + + # Handle per object settings + stack = n.callDecoration("getStack") + if stack is not None: + changed_setting_keys = set(stack.getTop().getAllKeys()) + + # Ensure that we save the extruder used for this object. + if stack.getProperty("machine_extruder_count", "value") > 1: + changed_setting_keys.add("extruder_nr") + + settings_xml = ET.SubElement(object, "settings", xmlns=self._namespaces["cura"]) + + # Get values for all changed settings & save them. + for key in changed_setting_keys: + setting_xml = ET.SubElement(settings_xml, "setting", key = key) + setting_xml.text = str(stack.getProperty(key, "value")) + + # Add one to the index as we haven't incremented the last iteration. + index += 1 + nodes_to_add = set() + + for node in added_nodes: + # Check the parents of the nodes with mesh_data and ensure that they are also added. + parent_node = node.getParent() + while parent_node is not None: + if parent_node.callDecoration("isGroup"): + nodes_to_add.add(parent_node) + parent_node = parent_node.getParent() + else: + parent_node = None + + # Sort all the nodes by depth (so nodes with the highest depth are done first) + sorted_nodes_to_add = sorted(nodes_to_add, key=lambda node: node.getDepth(), reverse = True) + + # We have already saved the nodes with mesh data, but now we also want to save nodes required for the scene + for node in sorted_nodes_to_add: + object = ET.SubElement(resources, "object", id=str(index + 1), type="model") + components = ET.SubElement(object, "components") + for child in node.getChildren(): + if child in added_nodes: + component = ET.SubElement(components, "component", objectid = str(added_nodes.index(child) + 1), transform = self._convertMatrixToString(child.getLocalTransformation())) + index += 1 + added_nodes.append(node) + + # Create a transformation Matrix to convert from our worldspace into 3MF. + # First step: flip the y and z axis. + transformation_matrix = Matrix() + transformation_matrix._data[1, 1] = 0 + transformation_matrix._data[1, 2] = -1 + transformation_matrix._data[2, 1] = 1 + transformation_matrix._data[2, 2] = 0 + + global_container_stack = UM.Application.getInstance().getGlobalContainerStack() + # Second step: 3MF defines the left corner of the machine as center, whereas cura uses the center of the + # build volume. + if global_container_stack: + translation_vector = Vector(x=global_container_stack.getProperty("machine_width", "value") / 2, + y=global_container_stack.getProperty("machine_depth", "value") / 2, + z=0) + translation_matrix = Matrix() + translation_matrix.setByTranslation(translation_vector) + transformation_matrix.preMultiply(translation_matrix) + + # Find out what the final build items are and add them. + for node in added_nodes: + if node.getParent().callDecoration("isGroup") is None: + node_matrix = node.getLocalTransformation() + + ET.SubElement(build, "item", objectid = str(added_nodes.index(node) + 1), transform = self._convertMatrixToString(node_matrix.preMultiply(transformation_matrix))) + + archive.writestr(model_file, b' \n' + ET.tostring(model)) + archive.writestr(content_types_file, b' \n' + ET.tostring(content_types)) + archive.writestr(relations_file, b' \n' + ET.tostring(relations_element)) + except Exception as e: + Logger.logException("e", "Error writing zip file") + return False + finally: + if not self._store_archive: + archive.close() + else: + self._archive = archive + + return True diff --git a/plugins/3MFWriter/__init__.py b/plugins/3MFWriter/__init__.py new file mode 100644 index 0000000000..1dbc0bf281 --- /dev/null +++ b/plugins/3MFWriter/__init__.py @@ -0,0 +1,38 @@ +# Copyright (c) 2015 Ultimaker B.V. +# Uranium is released under the terms of the AGPLv3 or higher. + +from UM.i18n import i18nCatalog +from . import ThreeMFWorkspaceWriter +from . import ThreeMFWriter + +i18n_catalog = i18nCatalog("uranium") + +def getMetaData(): + return { + "plugin": { + "name": i18n_catalog.i18nc("@label", "3MF Writer"), + "author": "Ultimaker", + "version": "1.0", + "description": i18n_catalog.i18nc("@info:whatsthis", "Provides support for writing 3MF files."), + "api": 3 + }, + "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 + }] + }, + "workspace_writer": { + "output": [{ + "extension": "3mf", + "description": i18n_catalog.i18nc("@item:inlistbox", "3MF file"), + "mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + "mode": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter.OutputMode.BinaryMode + }] + } + } + +def register(app): + return {"mesh_writer": ThreeMFWriter.ThreeMFWriter(), "workspace_writer": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter()} diff --git a/plugins/RemovableDriveOutputDevice/RemovableDriveOutputDevice.py b/plugins/RemovableDriveOutputDevice/RemovableDriveOutputDevice.py index 3fdd6b3e3e..b6505e7e6b 100644 --- a/plugins/RemovableDriveOutputDevice/RemovableDriveOutputDevice.py +++ b/plugins/RemovableDriveOutputDevice/RemovableDriveOutputDevice.py @@ -6,7 +6,7 @@ import os.path from UM.Application import Application from UM.Logger import Logger from UM.Message import Message -from UM.Mesh.WriteMeshJob import WriteMeshJob +from UM.FileHandler.WriteFileJob import WriteFileJob from UM.Mesh.MeshWriter import MeshWriter from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator from UM.OutputDevice.OutputDevice import OutputDevice @@ -37,13 +37,17 @@ class RemovableDriveOutputDevice(OutputDevice): # meshes. # \param limit_mimetypes Should we limit the available MIME types to the # MIME types available to the currently active machine? - def requestWrite(self, nodes, file_name = None, filter_by_machine = False): + def requestWrite(self, nodes, file_name = None, filter_by_machine = False, file_handler = None): filter_by_machine = True # This plugin is indended to be used by machine (regardless of what it was told to do) if self._writing: raise OutputDeviceError.DeviceBusyError() # Formats supported by this application (File types that we can actually write) - file_formats = Application.getInstance().getMeshFileHandler().getSupportedFileTypesWrite() + if file_handler: + file_formats = file_handler.getSupportedFileTypesWrite() + else: + file_formats = Application.getInstance().getMeshFileHandler().getSupportedFileTypesWrite() + if filter_by_machine: container = Application.getInstance().getGlobalContainerStack().findContainer({"file_formats": "*"}) @@ -58,7 +62,11 @@ class RemovableDriveOutputDevice(OutputDevice): raise OutputDeviceError.WriteRequestFailedError() # Just take the first file format available. - writer = Application.getInstance().getMeshFileHandler().getWriterByMimeType(file_formats[0]["mime_type"]) + if file_handler is not None: + writer = file_handler.getWriterByMimeType(file_formats[0]["mime_type"]) + else: + writer = Application.getInstance().getMeshFileHandler().getWriterByMimeType(file_formats[0]["mime_type"]) + extension = file_formats[0]["extension"] if file_name is None: @@ -72,7 +80,7 @@ class RemovableDriveOutputDevice(OutputDevice): Logger.log("d", "Writing to %s", file_name) # Using buffering greatly reduces the write time for many lines of gcode self._stream = open(file_name, "wt", buffering = 1, encoding = "utf-8") - job = WriteMeshJob(writer, self._stream, nodes, MeshWriter.OutputMode.TextMode) + job = WriteFileJob(writer, self._stream, nodes, MeshWriter.OutputMode.TextMode) job.setFileName(file_name) job.progress.connect(self._onProgress) job.finished.connect(self._onFinished) diff --git a/plugins/XmlMaterialProfile/XmlMaterialProfile.py b/plugins/XmlMaterialProfile/XmlMaterialProfile.py index e3ed28e94d..84a7d95a7c 100644 --- a/plugins/XmlMaterialProfile/XmlMaterialProfile.py +++ b/plugins/XmlMaterialProfile/XmlMaterialProfile.py @@ -350,10 +350,22 @@ class XmlMaterialProfile(UM.Settings.InstanceContainer): mapping[key] = element first.append(element) + def clearData(self): + self._metadata = {} + self._name = "" + self._definition = None + self._instances = {} + self._read_only = False + self._dirty = False + self._path = "" + ## Overridden from InstanceContainer def deserialize(self, serialized): data = ET.fromstring(serialized) + # Reset previous metadata + self.clearData() # Ensure any previous data is gone. + self.addMetaDataEntry("type", "material") self.addMetaDataEntry("base_file", self.id) @@ -455,7 +467,16 @@ class XmlMaterialProfile(UM.Settings.InstanceContainer): definition = definitions[0] if machine_compatibility: - new_material = XmlMaterialProfile(self.id + "_" + machine_id) + new_material_id = self.id + "_" + machine_id + + # It could be that we are overwriting, so check if the ID already exists. + materials = UM.Settings.ContainerRegistry.getInstance().findInstanceContainers(id=new_material_id) + if materials: + new_material = materials[0] + new_material.clearData() + else: + new_material = XmlMaterialProfile(new_material_id) + new_material.setName(self.getName()) new_material.setMetaData(copy.deepcopy(self.getMetaData())) new_material.setDefinition(definition) @@ -469,9 +490,8 @@ class XmlMaterialProfile(UM.Settings.InstanceContainer): new_material.setProperty(key, "value", value) new_material._dirty = False - - UM.Settings.ContainerRegistry.getInstance().addContainer(new_material) - + if not materials: + UM.Settings.ContainerRegistry.getInstance().addContainer(new_material) hotends = machine.iterfind("./um:hotend", self.__namespaces) for hotend in hotends: @@ -501,7 +521,15 @@ class XmlMaterialProfile(UM.Settings.InstanceContainer): else: Logger.log("d", "Unsupported material setting %s", key) - new_hotend_material = XmlMaterialProfile(self.id + "_" + machine_id + "_" + hotend_id.replace(" ", "_")) + # It could be that we are overwriting, so check if the ID already exists. + new_hotend_id = self.id + "_" + machine_id + "_" + hotend_id.replace(" ", "_") + materials = UM.Settings.ContainerRegistry.getInstance().findInstanceContainers(id=new_hotend_id) + if materials: + new_hotend_material = materials[0] + new_hotend_material.clearData() + else: + new_hotend_material = XmlMaterialProfile(new_hotend_id) + new_hotend_material.setName(self.getName()) new_hotend_material.setMetaData(copy.deepcopy(self.getMetaData())) new_hotend_material.setDefinition(definition) @@ -519,7 +547,8 @@ class XmlMaterialProfile(UM.Settings.InstanceContainer): new_hotend_material.setProperty(key, "value", value) new_hotend_material._dirty = False - UM.Settings.ContainerRegistry.getInstance().addContainer(new_hotend_material) + if not materials: # It was not added yet, do so now. + UM.Settings.ContainerRegistry.getInstance().addContainer(new_hotend_material) def _addSettingElement(self, builder, instance): try: diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/resources/qml/Actions.qml b/resources/qml/Actions.qml index 23fb452605..bb4e28eae7 100644 --- a/resources/qml/Actions.qml +++ b/resources/qml/Actions.qml @@ -11,6 +11,7 @@ import Cura 1.0 as Cura Item { property alias open: openAction; + property alias loadWorkspace: loadWorkspaceAction; property alias quit: quitAction; property alias undo: undoAction; @@ -286,6 +287,12 @@ Item shortcut: StandardKey.Open; } + Action + { + id: loadWorkspaceAction + text: catalog.i18nc("@action:inmenu menubar:file","&Open Workspace..."); + } + Action { id: showEngineLogAction; diff --git a/resources/qml/Cura.qml b/resources/qml/Cura.qml index ab4d7bfd06..85be3342e9 100644 --- a/resources/qml/Cura.qml +++ b/resources/qml/Cura.qml @@ -7,7 +7,7 @@ import QtQuick.Controls.Styles 1.1 import QtQuick.Layouts 1.1 import QtQuick.Dialogs 1.1 -import UM 1.2 as UM +import UM 1.3 as UM import Cura 1.0 as Cura import "Menus" @@ -67,9 +67,14 @@ UM.MainWindow id: fileMenu title: catalog.i18nc("@title:menu menubar:toplevel","&File"); - MenuItem { + MenuItem + { action: Cura.Actions.open; } + MenuItem + { + action: Cura.Actions.loadWorkspace + } RecentFilesMenu { } @@ -102,6 +107,12 @@ UM.MainWindow onObjectRemoved: saveAllMenu.removeItem(object) } } + MenuItem + { + id: saveWorkspaceMenu + text: catalog.i18nc("@title:menu menubar:file","Save Workspace") + onTriggered: UM.OutputDeviceManager.requestWriteToDevice("local_file", PrintInformation.jobName, { "filter_by_machine": false, "file_type": "workspace" }); + } MenuItem { action: Cura.Actions.reloadAll; } @@ -723,6 +734,38 @@ UM.MainWindow onTriggered: openDialog.open() } + FileDialog + { + id: openWorkspaceDialog; + + //: File open dialog title + title: catalog.i18nc("@title:window","Open workspace") + modality: UM.Application.platform == "linux" ? Qt.NonModal : Qt.WindowModal; + selectMultiple: false + nameFilters: UM.WorkspaceFileHandler.supportedReadFileTypes; + folder: CuraApplication.getDefaultPath("dialog_load_path") + onAccepted: + { + //Because several implementations of the file dialog only update the folder + //when it is explicitly set. + var f = folder; + folder = f; + + CuraApplication.setDefaultPath("dialog_load_path", folder); + + for(var i in fileUrls) + { + UM.WorkspaceFileHandler.readLocalFile(fileUrls[i]) + } + } + } + + Connections + { + target: Cura.Actions.loadWorkspace + onTriggered:openWorkspaceDialog.open() + } + EngineLog { id: engineLog;