diff --git a/.github/no-response.yml b/.github/no-response.yml new file mode 100644 index 0000000000..a386aaa7ba --- /dev/null +++ b/.github/no-response.yml @@ -0,0 +1,13 @@ +# Configuration for probot-no-response - https://github.com/probot/no-response + +# Number of days of inactivity before an Issue is closed for lack of response +daysUntilClose: 14 +# Label requiring a response +responseRequiredLabel: 'Status: Needs Info' +# Comment to post when closing an Issue for lack of response. Set to `false` to disable +closeComment: > + This issue has been automatically closed because there has been no response + to our request for more information from the original author. With only the + information that is currently in the issue, we don't have enough information + to take action. Please reach out if you have or find the answers we need so + that we can investigate further. diff --git a/cura/Arranging/Arrange.py b/cura/Arranging/Arrange.py index 4333cbd74a..c9d3498c7b 100644 --- a/cura/Arranging/Arrange.py +++ b/cura/Arranging/Arrange.py @@ -80,8 +80,11 @@ class Arrange: # After scaling (like up to 0.1 mm) the node might not have points if not points.size: continue - - shape_arr = ShapeArray.fromPolygon(points, scale = scale) + try: + shape_arr = ShapeArray.fromPolygon(points, scale = scale) + except ValueError: + Logger.logException("w", "Unable to create polygon") + continue arranger.place(0, 0, shape_arr) # If a build volume was set, add the disallowed areas diff --git a/cura/AutoSave.py b/cura/AutoSave.py index 2c1dbe4a84..d80e34771e 100644 --- a/cura/AutoSave.py +++ b/cura/AutoSave.py @@ -31,7 +31,6 @@ class AutoSave: self._change_timer.timeout.connect(self._onTimeout) self._application.globalContainerStackChanged.connect(self._onGlobalStackChanged) self._onGlobalStackChanged() - self._triggerTimer() def _triggerTimer(self, *args: Any) -> None: if not self._saving: diff --git a/cura/CrashHandler.py b/cura/CrashHandler.py index a2c9ede8ef..4c0dd4855b 100644 --- a/cura/CrashHandler.py +++ b/cura/CrashHandler.py @@ -215,6 +215,16 @@ class CrashHandler: locale.getdefaultlocale()[0] self.data["locale_cura"] = self.cura_locale + try: + from cura.CuraApplication import CuraApplication + plugins = CuraApplication.getInstance().getPluginRegistry() + self.data["plugins"] = { + plugin_id: plugins.getMetaData(plugin_id)["plugin"]["version"] + for plugin_id in plugins.getInstalledPlugins() if not plugins.isBundledPlugin(plugin_id) + } + except: + self.data["plugins"] = {"[FAILED]": "0.0.0"} + crash_info = "" + catalog.i18nc("@label Cura version number", "Cura version") + ": " + str(self.cura_version) + "
" crash_info += "" + catalog.i18nc("@label", "Cura language") + ": " + str(self.cura_locale) + "
" crash_info += "" + catalog.i18nc("@label", "OS language") + ": " + str(self.data["locale_os"]) + "
" @@ -238,6 +248,8 @@ class CrashHandler: scope.set_tag("locale_cura", self.cura_locale) scope.set_tag("is_enterprise", ApplicationMetadata.IsEnterpriseVersion) + scope.set_context("plugins", self.data["plugins"]) + scope.set_user({"id": str(uuid.getnode())}) return group diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index dfb5c6cac1..bae212917a 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -756,7 +756,6 @@ class CuraApplication(QtApplication): if not hasattr(sys, "frozen"): self._plugin_registry.addPluginLocation(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "plugins")) self._plugin_registry.loadPlugin("ConsoleLogger") - self._plugin_registry.loadPlugin("CuraEngineBackend") self._plugin_registry.loadPlugins() diff --git a/cura/Scene/CuraSceneNode.py b/cura/Scene/CuraSceneNode.py index 7337a3a32f..c22277a4b0 100644 --- a/cura/Scene/CuraSceneNode.py +++ b/cura/Scene/CuraSceneNode.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from copy import deepcopy @@ -119,9 +119,9 @@ class CuraSceneNode(SceneNode): self._aabb = None if self._mesh_data: self._aabb = self._mesh_data.getExtents(self.getWorldTransformation()) - else: # If there is no mesh_data, use a boundingbox that encompasses the local (0,0,0) + else: # If there is no mesh_data, use a bounding box that encompasses the local (0,0,0) position = self.getWorldPosition() - self._aabb = AxisAlignedBox(minimum=position, maximum=position) + self._aabb = AxisAlignedBox(minimum = position, maximum = position) for child in self.getAllChildren(): if child.callDecoration("isNonPrintingMesh"): diff --git a/cura/Settings/CuraContainerStack.py b/cura/Settings/CuraContainerStack.py index 36548ed5de..4595bf3996 100755 --- a/cura/Settings/CuraContainerStack.py +++ b/cura/Settings/CuraContainerStack.py @@ -60,6 +60,8 @@ class CuraContainerStack(ContainerStack): import cura.CuraApplication #Here to prevent circular imports. self.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.SettingVersion) + self.setDirty(False) + # This is emitted whenever the containersChanged signal from the ContainerStack base class is emitted. pyqtContainersChanged = pyqtSignal() diff --git a/cura/Settings/ExtruderStack.py b/cura/Settings/ExtruderStack.py index 7520d436e9..bb35b336c7 100644 --- a/cura/Settings/ExtruderStack.py +++ b/cura/Settings/ExtruderStack.py @@ -32,6 +32,8 @@ class ExtruderStack(CuraContainerStack): self.propertiesChanged.connect(self._onPropertiesChanged) + self.setDirty(False) + enabledChanged = pyqtSignal() @override(ContainerStack) diff --git a/cura/Settings/GlobalStack.py b/cura/Settings/GlobalStack.py index 929c567921..a9164d0fb9 100755 --- a/cura/Settings/GlobalStack.py +++ b/cura/Settings/GlobalStack.py @@ -55,6 +55,8 @@ class GlobalStack(CuraContainerStack): # properties. So we need to tie them together like this. self.metaDataChanged.connect(self.configuredConnectionTypesChanged) + self.setDirty(False) + extrudersChanged = pyqtSignal() configuredConnectionTypesChanged = pyqtSignal() diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 423df167cd..1934befd66 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -738,14 +738,15 @@ class MachineManager(QObject): containers = CuraContainerRegistry.getInstance().findInstanceContainersMetadata(type = "user", machine = machine_id) for container in containers: CuraContainerRegistry.getInstance().removeContainer(container["id"]) - machine_stack = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine", name = machine_id)[0] - CuraContainerRegistry.getInstance().removeContainer(machine_stack.definitionChanges.getId()) + machine_stacks = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine", name = machine_id) + if machine_stacks: + CuraContainerRegistry.getInstance().removeContainer(machine_stacks[0].definitionChanges.getId()) CuraContainerRegistry.getInstance().removeContainer(machine_id) # If the printer that is being removed is a network printer, the hidden printers have to be also removed group_id = metadata.get("group_id", None) if group_id: - metadata_filter = {"group_id": group_id} + metadata_filter = {"group_id": group_id, "hidden": True} hidden_containers = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine", **metadata_filter) if hidden_containers: # This reuses the method and remove all printers recursively @@ -1368,7 +1369,6 @@ class MachineManager(QObject): with postponeSignals(*self._getContainerChangedSignals(), compress = CompressTechnique.CompressPerParameterValue): self.switchPrinterType(configuration.printerType) - disabled_used_extruder_position_set = set() extruders_to_disable = set() # If an extruder that's currently used to print a model gets disabled due to the syncing, we need to show @@ -1377,8 +1377,8 @@ class MachineManager(QObject): for extruder_configuration in configuration.extruderConfigurations: # We support "" or None, since the cloud uses None instead of empty strings - extruder_has_hotend = extruder_configuration.hotendID and extruder_configuration.hotendID != "" - extruder_has_material = extruder_configuration.material.guid and extruder_configuration.material.guid != "" + extruder_has_hotend = extruder_configuration.hotendID not in ["", None] + extruder_has_material = extruder_configuration.material.guid not in [None, "", "00000000-0000-0000-0000-000000000000"] # If the machine doesn't have a hotend or material, disable this extruder if not extruder_has_hotend or not extruder_has_material: @@ -1396,7 +1396,6 @@ class MachineManager(QObject): self._global_container_stack.extruderList[int(position)].setEnabled(False) need_to_show_message = True - disabled_used_extruder_position_set.add(int(position)) else: machine_node = ContainerTree.getInstance().machines.get(self._global_container_stack.definition.getId()) @@ -1427,7 +1426,7 @@ class MachineManager(QObject): # Show human-readable extruder names such as "Extruder Left", "Extruder Front" instead of "Extruder 1, 2, 3". extruder_names = [] - for extruder_position in sorted(disabled_used_extruder_position_set): + for extruder_position in sorted(extruders_to_disable): extruder_stack = self._global_container_stack.extruderList[int(extruder_position)] extruder_name = extruder_stack.definition.getName() extruder_names.append(extruder_name) diff --git a/docker/build.sh b/docker/build.sh index a500663c64..39632b348b 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -67,4 +67,4 @@ cmake3 \ -DBUILD_TESTS=ON \ .. make -ctest3 --output-on-failure -T Test +ctest3 -j4 --output-on-failure -T Test diff --git a/plugins/3MFReader/ThreeMFWorkspaceReader.py b/plugins/3MFReader/ThreeMFWorkspaceReader.py index 2d257bb4b4..eaac225e00 100755 --- a/plugins/3MFReader/ThreeMFWorkspaceReader.py +++ b/plugins/3MFReader/ThreeMFWorkspaceReader.py @@ -651,7 +651,13 @@ class ThreeMFWorkspaceReader(WorkspaceReader): self._container_registry.addContainer(global_stack) else: # Find the machine - global_stack = self._container_registry.findContainerStacks(name = self._machine_info.name, type = "machine")[0] + global_stacks = self._container_registry.findContainerStacks(name = self._machine_info.name, type = "machine") + if not global_stacks: + 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.show() + self.setWorkspaceName("") + return [], {} + global_stack = global_stacks[0] extruder_stacks = self._container_registry.findContainerStacks(machine = global_stack.getId(), type = "extruder_train") extruder_stack_dict = {stack.getMetaDataEntry("position"): stack for stack in extruder_stacks} diff --git a/plugins/PostProcessingPlugin/scripts/PauseAtHeight.py b/plugins/PostProcessingPlugin/scripts/PauseAtHeight.py index 114ab4d34a..aa879ef889 100644 --- a/plugins/PostProcessingPlugin/scripts/PauseAtHeight.py +++ b/plugins/PostProcessingPlugin/scripts/PauseAtHeight.py @@ -204,10 +204,11 @@ class PauseAtHeight(Script): """Get the X and Y values for a layer (will be used to get X and Y of the layer after the pause).""" lines = layer.split("\n") for line in lines: - if self.getValue(line, "X") is not None and self.getValue(line, "Y") is not None: - x = self.getValue(line, "X") - y = self.getValue(line, "Y") - return x, y + if line.startswith(("G0", "G1", "G2", "G3")): + if self.getValue(line, "X") is not None and self.getValue(line, "Y") is not None: + x = self.getValue(line, "X") + y = self.getValue(line, "Y") + return x, y return 0, 0 def execute(self, data: List[str]) -> List[str]: diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 0ad9f7c89c..876ca586a7 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Toolbox is released under the terms of the LGPLv3 or higher. import json @@ -232,7 +232,7 @@ class Toolbox(QObject, Extension): "licenseModel": self._license_model }) if not dialog: - raise Exception("Failed to create Marketplace dialog") + return None return dialog def _convertPluginMetadata(self, plugin_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index da200c7fc2..322919124f 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -31,6 +31,7 @@ class CloudOutputDeviceManager: """ META_CLUSTER_ID = "um_cloud_cluster_id" + META_HOST_GUID = "host_guid" META_NETWORK_KEY = "um_network_key" SYNC_SERVICE_NAME = "CloudOutputDeviceManager" @@ -113,13 +114,18 @@ class CloudOutputDeviceManager: all_clusters = {c.cluster_id: c for c in clusters} # type: Dict[str, CloudClusterResponse] online_clusters = {c.cluster_id: c for c in clusters if c.is_online} # type: Dict[str, CloudClusterResponse] - # Add the new printers in Cura. If a printer was previously added and is rediscovered, set its metadata to - # reflect that and mark the printer not removed from the account + # Add the new printers in Cura. for device_id, cluster_data in all_clusters.items(): if device_id not in self._remote_clusters: new_clusters.append(cluster_data) - if device_id in self._um_cloud_printers and not parseBool(self._um_cloud_printers[device_id].getMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, "true")): - self._um_cloud_printers[device_id].setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, True) + if device_id in self._um_cloud_printers: + # Existing cloud printers may not have the host_guid meta-data entry. If that's the case, add it. + if not self._um_cloud_printers[device_id].getMetaDataEntry(self.META_HOST_GUID, None): + self._um_cloud_printers[device_id].setMetaDataEntry(self.META_HOST_GUID, cluster_data.host_guid) + # If a printer was previously not linked to the account and is rediscovered, mark the printer as linked + # to the current account + if not parseBool(self._um_cloud_printers[device_id].getMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, "true")): + self._um_cloud_printers[device_id].setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, True) self._onDevicesDiscovered(new_clusters) # Hide the current removed_printers_message, if there is any @@ -161,11 +167,22 @@ class CloudOutputDeviceManager: """ new_devices = [] remote_clusters_added = False + host_guid_map = {machine.getMetaDataEntry(self.META_HOST_GUID): device_cluster_id + for device_cluster_id, machine in self._um_cloud_printers.items() + if machine.getMetaDataEntry(self.META_HOST_GUID)} + machine_manager = CuraApplication.getInstance().getMachineManager() + for cluster_data in clusters: device = CloudOutputDevice(self._api, cluster_data) - # Create a machine if we don't already have it. Do not make it the active machine. - machine_manager = CuraApplication.getInstance().getMachineManager() + # If the machine already existed before, it will be present in the host_guid_map + if cluster_data.host_guid in host_guid_map: + machine = machine_manager.getMachine(device.printerType, {self.META_HOST_GUID: cluster_data.host_guid}) + if machine and machine.getMetaDataEntry(self.META_CLUSTER_ID) != device.key: + # If the retrieved device has a different cluster_id than the existing machine, bring the existing + # machine up-to-date. + self._updateOutdatedMachine(outdated_machine = machine, new_cloud_output_device = device) + # Create a machine if we don't already have it. Do not make it the active machine. # We only need to add it if it wasn't already added by "local" network or by cloud. if machine_manager.getMachine(device.printerType, {self.META_CLUSTER_ID: device.key}) is None \ and machine_manager.getMachine(device.printerType, {self.META_NETWORK_KEY: cluster_data.host_name + "*"}) is None: # The host name is part of the network key. @@ -239,17 +256,42 @@ class CloudOutputDeviceManager: num_hidden = len(new_devices) - max_disp_devices + 1 device_name_list = ["
  • {} ({})
  • ".format(device.name, device.printerTypeName) for device in new_devices[0:num_hidden]] device_name_list.append(self.I18N_CATALOG.i18nc("info:hidden list items", "
  • ... and {} others
  • ", num_hidden)) - device_names = "\n".join(device_name_list) + device_names = "".join(device_name_list) else: - device_names = "\n".join(["
  • {} ({})
  • ".format(device.name, device.printerTypeName) for device in new_devices]) + device_names = "".join(["
  • {} ({})
  • ".format(device.name, device.printerTypeName) for device in new_devices]) message_text = self.I18N_CATALOG.i18nc( "info:status", - "Cloud printers added from your account:\n", + "Cloud printers added from your account:", device_names ) message.setText(message_text) + def _updateOutdatedMachine(self, outdated_machine: GlobalStack, new_cloud_output_device: CloudOutputDevice) -> None: + """ + Update the cloud metadata of a pre-existing machine that is rediscovered (e.g. if the printer was removed and + re-added to the account) and delete the old CloudOutputDevice related to this machine. + + :param outdated_machine: The cloud machine that needs to be brought up-to-date with the new data received from + the account + :param new_cloud_output_device: The new CloudOutputDevice that should be linked to the pre-existing machine + :return: None + """ + old_cluster_id = outdated_machine.getMetaDataEntry(self.META_CLUSTER_ID) + outdated_machine.setMetaDataEntry(self.META_CLUSTER_ID, new_cloud_output_device.key) + outdated_machine.setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, True) + # Cleanup the remainings of the old CloudOutputDevice(old_cluster_id) + self._um_cloud_printers[new_cloud_output_device.key] = self._um_cloud_printers.pop(old_cluster_id) + output_device_manager = CuraApplication.getInstance().getOutputDeviceManager() + if old_cluster_id in output_device_manager.getOutputDeviceIds(): + output_device_manager.removeOutputDevice(old_cluster_id) + if old_cluster_id in self._remote_clusters: + # We need to close the device so that it stops checking for its status + self._remote_clusters[old_cluster_id].close() + del self._remote_clusters[old_cluster_id] + self._remote_clusters[new_cloud_output_device.key] = new_cloud_output_device + + def _devicesRemovedFromAccount(self, removed_device_ids: Set[str]) -> None: """ Removes the CloudOutputDevice from the received device ids and marks the specific printers as "removed from @@ -378,6 +420,7 @@ class CloudOutputDeviceManager: def _setOutputDeviceMetadata(self, device: CloudOutputDevice, machine: GlobalStack): machine.setName(device.name) machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) + machine.setMetaDataEntry(self.META_HOST_GUID, device.clusterData.host_guid) machine.setMetaDataEntry("group_name", device.name) machine.setMetaDataEntry("group_size", device.clusterSize) machine.setMetaDataEntry("removal_warning", self.I18N_CATALOG.i18nc( diff --git a/resources/definitions/fdmprinter.def.json b/resources/definitions/fdmprinter.def.json index 323e9e16a7..babd4cca3e 100644 --- a/resources/definitions/fdmprinter.def.json +++ b/resources/definitions/fdmprinter.def.json @@ -4474,6 +4474,19 @@ "enabled": "support_enable or support_meshes_present", "settable_per_mesh": true }, + "support_bottom_stair_step_min_slope": + { + "label": "Support Stair Step Minimum Slope Angle", + "description": "The minimum slope of the area for stair-stepping to take effect. Low values should make support easier to remove on shallower slopes, but really low values may result in some very counter-intuitive results on other parts of the model.", + "unit": "°", + "type": "float", + "default_value": 10.0, + "limit_to_extruder": "support_bottom_extruder_nr if support_bottom_enable else support_infill_extruder_nr", + "minimum_value": "0.01", + "maximum_value": "89.99", + "enabled": "support_enable or support_meshes_present", + "settable_per_mesh": true + }, "support_join_distance": { "label": "Support Join Distance", diff --git a/tests/Settings/MockContainer.py b/tests/Settings/MockContainer.py index bb99710ef6..9c20f55405 100644 --- a/tests/Settings/MockContainer.py +++ b/tests/Settings/MockContainer.py @@ -153,6 +153,9 @@ class MockContainer(ContainerInterface, UM.PluginObject.PluginObject): def isDirty(self): return True + def setDirty(self, dirty): + pass + metaDataChanged = Signal() propertyChanged = Signal() containersChanged = Signal() diff --git a/tests/Settings/TestDefinitionContainer.py b/tests/Settings/TestDefinitionContainer.py index 8622db26ee..d434066d10 100644 --- a/tests/Settings/TestDefinitionContainer.py +++ b/tests/Settings/TestDefinitionContainer.py @@ -12,7 +12,7 @@ from unittest.mock import patch, MagicMock import UM.Settings.ContainerRegistry #To create empty instance containers. import UM.Settings.ContainerStack #To set the container registry the container stacks use. from UM.Settings.DefinitionContainer import DefinitionContainer #To check against the class of DefinitionContainer. - +from UM.VersionUpgradeManager import FilesDataUpdateResult from UM.Resources import Resources Resources.addSearchPath(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "resources"))) @@ -36,6 +36,7 @@ def definition_container(): assert result.getId() == uid return result + @pytest.mark.parametrize("file_path", definition_filepaths) def test_definitionIds(file_path): """ @@ -45,6 +46,7 @@ def test_definitionIds(file_path): definition_id = os.path.basename(file_path).split(".")[0] assert " " not in definition_id # Definition IDs are not allowed to have spaces. + @pytest.mark.parametrize("file_path", definition_filepaths) def test_noCategory(file_path): """ @@ -57,6 +59,7 @@ def test_noCategory(file_path): metadata = DefinitionContainer.deserializeMetadata(json, "test_container_id") assert "category" not in metadata[0] + @pytest.mark.parametrize("file_path", machine_filepaths) def test_validateMachineDefinitionContainer(file_path, definition_container): """Tests all definition containers""" @@ -65,13 +68,12 @@ def test_validateMachineDefinitionContainer(file_path, definition_container): if file_name == "fdmprinter.def.json" or file_name == "fdmextruder.def.json": return # Stop checking, these are root files. - from UM.VersionUpgradeManager import FilesDataUpdateResult - mocked_vum = MagicMock() mocked_vum.updateFilesData = lambda ct, v, fdl, fnl: FilesDataUpdateResult(ct, v, fdl, fnl) with patch("UM.VersionUpgradeManager.VersionUpgradeManager.getInstance", MagicMock(return_value = mocked_vum)): assertIsDefinitionValid(definition_container, file_path) + def assertIsDefinitionValid(definition_container, file_path): with open(file_path, encoding = "utf-8") as data: json = data.read() @@ -86,6 +88,7 @@ def assertIsDefinitionValid(definition_container, file_path): if "platform_texture" in metadata[0]: assert metadata[0]["platform_texture"] in all_images + @pytest.mark.parametrize("file_path", definition_filepaths) def test_validateOverridingDefaultValue(file_path: str): """Tests whether setting values are not being hidden by parent containers. @@ -189,7 +192,9 @@ def test_noId(file_path: str): @pytest.mark.parametrize("file_path", extruder_filepaths) def test_extruderMatch(file_path: str): - """Verifies that extruders say that they work on the same extruder_nr as what is listed in their machine definition.""" + """ + Verifies that extruders say that they work on the same extruder_nr as what is listed in their machine definition. + """ extruder_id = os.path.basename(file_path).split(".")[0] with open(file_path, encoding = "utf-8") as f: