diff --git a/.gitignore b/.gitignore index d991fedb73..98eaa6f414 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ plugins/cura-siemensnx-plugin plugins/CuraBlenderPlugin plugins/CuraCloudPlugin plugins/CuraDrivePlugin +plugins/CuraDrive plugins/CuraLiveScriptingPlugin plugins/CuraOpenSCADPlugin plugins/CuraPrintProfileCreator diff --git a/cura/API/Backups.py b/cura/API/Backups.py new file mode 100644 index 0000000000..ba416bd870 --- /dev/null +++ b/cura/API/Backups.py @@ -0,0 +1,32 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from cura.Backups.BackupsManager import BackupsManager + + +class Backups: + """ + The backups API provides a version-proof bridge between Cura's BackupManager and plugins that hook into it. + + Usage: + from cura.API import CuraAPI + api = CuraAPI() + api.backups.createBackup() + api.backups.restoreBackup(my_zip_file, {"cura_release": "3.1"}) + """ + + manager = BackupsManager() # Re-used instance of the backups manager. + + def createBackup(self) -> (bytes, dict): + """ + Create a new backup using the BackupsManager. + :return: Tuple containing a ZIP file with the backup data and a dict with meta data about the backup. + """ + return self.manager.createBackup() + + def restoreBackup(self, zip_file: bytes, meta_data: dict) -> None: + """ + Restore a backup using the BackupManager. + :param zip_file: A ZIP file containing the actual backup data. + :param meta_data: Some meta data needed for restoring a backup, like the Cura version number. + """ + return self.manager.restoreBackup(zip_file, meta_data) diff --git a/cura/API/__init__.py b/cura/API/__init__.py new file mode 100644 index 0000000000..7dd5d8f79e --- /dev/null +++ b/cura/API/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from UM.PluginRegistry import PluginRegistry +from cura.API.Backups import Backups + + +class CuraAPI: + """ + The official Cura API that plugins can use to interact with Cura. + Python does not technically prevent talking to other classes as well, + but this API provides a version-safe interface with proper deprecation warnings etc. + Usage of any other methods than the ones provided in this API can cause plugins to be unstable. + """ + + # For now we use the same API version to be consistent. + VERSION = PluginRegistry.APIVersion + + # Backups API. + backups = Backups() diff --git a/cura/Arranging/Arrange.py b/cura/Arranging/Arrange.py index a90a97c3c2..1027b39199 100644 --- a/cura/Arranging/Arrange.py +++ b/cura/Arranging/Arrange.py @@ -1,3 +1,6 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Logger import Logger from UM.Math.Vector import Vector @@ -18,17 +21,20 @@ LocationSuggestion = namedtuple("LocationSuggestion", ["x", "y", "penalty_points # good locations for objects that you try to put on a build place. # Different priority schemes can be defined so it alters the behavior while using # the same logic. +# +# Note: Make sure the scale is the same between ShapeArray objects and the Arrange instance. class Arrange: build_volume = None - def __init__(self, x, y, offset_x, offset_y, scale= 1.0): - self.shape = (y, x) - self._priority = numpy.zeros((x, y), dtype=numpy.int32) - self._priority_unique_values = [] - self._occupied = numpy.zeros((x, y), dtype=numpy.int32) + def __init__(self, x, y, offset_x, offset_y, scale= 0.5): self._scale = scale # convert input coordinates to arrange coordinates - self._offset_x = offset_x - self._offset_y = offset_y + world_x, world_y = int(x * self._scale), int(y * self._scale) + self._shape = (world_y, world_x) + self._priority = numpy.zeros((world_y, world_x), dtype=numpy.int32) # beware: these are indexed (y, x) + self._priority_unique_values = [] + self._occupied = numpy.zeros((world_y, world_x), dtype=numpy.int32) # beware: these are indexed (y, x) + self._offset_x = int(offset_x * self._scale) + self._offset_y = int(offset_y * self._scale) self._last_priority = 0 self._is_empty = True @@ -39,7 +45,7 @@ class Arrange: # \param scene_root Root for finding all scene nodes # \param fixed_nodes Scene nodes to be placed @classmethod - def create(cls, scene_root = None, fixed_nodes = None, scale = 0.5, x = 220, y = 220): + def create(cls, scene_root = None, fixed_nodes = None, scale = 0.5, x = 350, y = 250): arranger = Arrange(x, y, x // 2, y // 2, scale = scale) arranger.centerFirst() @@ -61,13 +67,17 @@ class Arrange: # If a build volume was set, add the disallowed areas if Arrange.build_volume: - disallowed_areas = Arrange.build_volume.getDisallowedAreas() + disallowed_areas = Arrange.build_volume.getDisallowedAreasNoBrim() for area in disallowed_areas: points = copy.deepcopy(area._points) shape_arr = ShapeArray.fromPolygon(points, scale = scale) arranger.place(0, 0, shape_arr, update_empty = False) return arranger + ## This resets the optimization for finding location based on size + def resetLastPriority(self): + self._last_priority = 0 + ## Find placement for a node (using offset shape) and place it (using hull shape) # return the nodes that should be placed # \param node @@ -104,7 +114,7 @@ class Arrange: def centerFirst(self): # Square distance: creates a more round shape self._priority = numpy.fromfunction( - lambda i, j: (self._offset_x - i) ** 2 + (self._offset_y - j) ** 2, self.shape, dtype=numpy.int32) + lambda j, i: (self._offset_x - i) ** 2 + (self._offset_y - j) ** 2, self._shape, dtype=numpy.int32) self._priority_unique_values = numpy.unique(self._priority) self._priority_unique_values.sort() @@ -112,7 +122,7 @@ class Arrange: # This is a strategy for the arranger. def backFirst(self): self._priority = numpy.fromfunction( - lambda i, j: 10 * j + abs(self._offset_x - i), self.shape, dtype=numpy.int32) + lambda j, i: 10 * j + abs(self._offset_x - i), self._shape, dtype=numpy.int32) self._priority_unique_values = numpy.unique(self._priority) self._priority_unique_values.sort() @@ -126,9 +136,15 @@ class Arrange: y = int(self._scale * y) offset_x = x + self._offset_x + shape_arr.offset_x offset_y = y + self._offset_y + shape_arr.offset_y + if offset_x < 0 or offset_y < 0: + return None # out of bounds in self._occupied + occupied_x_max = offset_x + shape_arr.arr.shape[1] + occupied_y_max = offset_y + shape_arr.arr.shape[0] + if occupied_x_max > self._occupied.shape[1] + 1 or occupied_y_max > self._occupied.shape[0] + 1: + return None # out of bounds in self._occupied occupied_slice = self._occupied[ - offset_y:offset_y + shape_arr.arr.shape[0], - offset_x:offset_x + shape_arr.arr.shape[1]] + offset_y:occupied_y_max, + offset_x:occupied_x_max] try: if numpy.any(occupied_slice[numpy.where(shape_arr.arr == 1)]): return None @@ -140,7 +156,7 @@ class Arrange: return numpy.sum(prio_slice[numpy.where(shape_arr.arr == 1)]) ## Find "best" spot for ShapeArray - # Return namedtuple with properties x, y, penalty_points, priority + # Return namedtuple with properties x, y, penalty_points, priority. # \param shape_arr ShapeArray # \param start_prio Start with this priority value (and skip the ones before) # \param step Slicing value, higher = more skips = faster but less accurate @@ -153,12 +169,11 @@ class Arrange: for priority in self._priority_unique_values[start_idx::step]: tryout_idx = numpy.where(self._priority == priority) for idx in range(len(tryout_idx[0])): - x = tryout_idx[0][idx] - y = tryout_idx[1][idx] - projected_x = x - self._offset_x - projected_y = y - self._offset_y + x = tryout_idx[1][idx] + y = tryout_idx[0][idx] + projected_x = int((x - self._offset_x) / self._scale) + projected_y = int((y - self._offset_y) / self._scale) - # array to "world" coordinates penalty_points = self.checkShape(projected_x, projected_y, shape_arr) if penalty_points is not None: return LocationSuggestion(x = projected_x, y = projected_y, penalty_points = penalty_points, priority = priority) @@ -191,8 +206,12 @@ class Arrange: # Set priority to low (= high number), so it won't get picked at trying out. prio_slice = self._priority[min_y:max_y, min_x:max_x] - prio_slice[numpy.where(shape_arr.arr[ - min_y - offset_y:max_y - offset_y, min_x - offset_x:max_x - offset_x] == 1)] = 999 + prio_slice[new_occupied] = 999 + + # If you want to see how the rasterized arranger build plate looks like, uncomment this code + # numpy.set_printoptions(linewidth=500, edgeitems=200) + # print(self._occupied.shape) + # print(self._occupied) @property def isEmpty(self): diff --git a/cura/Arranging/ArrangeObjectsAllBuildPlatesJob.py b/cura/Arranging/ArrangeObjectsAllBuildPlatesJob.py index 3f23b0dbe7..252cef4e65 100644 --- a/cura/Arranging/ArrangeObjectsAllBuildPlatesJob.py +++ b/cura/Arranging/ArrangeObjectsAllBuildPlatesJob.py @@ -1,6 +1,7 @@ -# Copyright (c) 2017 Ultimaker B.V. +# Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from UM.Application import Application from UM.Job import Job from UM.Scene.SceneNode import SceneNode from UM.Math.Vector import Vector @@ -17,6 +18,7 @@ from cura.Arranging.ShapeArray import ShapeArray from typing import List +## Do arrangements on multiple build plates (aka builtiplexer) class ArrangeArray: def __init__(self, x: int, y: int, fixed_nodes: List[SceneNode]): self._x = x @@ -79,7 +81,11 @@ class ArrangeObjectsAllBuildPlatesJob(Job): nodes_arr.sort(key=lambda item: item[0]) nodes_arr.reverse() - x, y = 200, 200 + global_container_stack = Application.getInstance().getGlobalContainerStack() + machine_width = global_container_stack.getProperty("machine_width", "value") + machine_depth = global_container_stack.getProperty("machine_depth", "value") + + x, y = machine_width, machine_depth arrange_array = ArrangeArray(x = x, y = y, fixed_nodes = []) arrange_array.add() @@ -93,27 +99,18 @@ class ArrangeObjectsAllBuildPlatesJob(Job): for idx, (size, node, offset_shape_arr, hull_shape_arr) in enumerate(nodes_arr): # For performance reasons, we assume that when a location does not fit, # it will also not fit for the next object (while what can be untrue). - # We also skip possibilities by slicing through the possibilities (step = 10) try_placement = True current_build_plate_number = 0 # always start with the first one - # # Only for first build plate - # if last_size == size and last_build_plate_number == current_build_plate_number: - # # This optimization works if many of the objects have the same size - # # Continue with same build plate number - # start_priority = last_priority - # else: - # start_priority = 0 - while try_placement: # make sure that current_build_plate_number is not going crazy or you'll have a lot of arrange objects while current_build_plate_number >= arrange_array.count(): arrange_array.add() arranger = arrange_array.get(current_build_plate_number) - best_spot = arranger.bestSpot(offset_shape_arr, start_prio=start_priority, step=10) + best_spot = arranger.bestSpot(offset_shape_arr, start_prio=start_priority) x, y = best_spot.x, best_spot.y node.removeDecorator(ZOffsetDecorator) if node.getBoundingBox(): diff --git a/cura/Arranging/ArrangeObjectsJob.py b/cura/Arranging/ArrangeObjectsJob.py index 765c3333cb..01a91a3c22 100644 --- a/cura/Arranging/ArrangeObjectsJob.py +++ b/cura/Arranging/ArrangeObjectsJob.py @@ -1,6 +1,7 @@ # Copyright (c) 2017 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from UM.Application import Application from UM.Job import Job from UM.Scene.SceneNode import SceneNode from UM.Math.Vector import Vector @@ -32,7 +33,11 @@ class ArrangeObjectsJob(Job): progress = 0, title = i18n_catalog.i18nc("@info:title", "Finding Location")) status_message.show() - arranger = Arrange.create(fixed_nodes = self._fixed_nodes) + global_container_stack = Application.getInstance().getGlobalContainerStack() + machine_width = global_container_stack.getProperty("machine_width", "value") + machine_depth = global_container_stack.getProperty("machine_depth", "value") + + arranger = Arrange.create(x = machine_width, y = machine_depth, fixed_nodes = self._fixed_nodes) # Collect nodes to be placed nodes_arr = [] # fill with (size, node, offset_shape_arr, hull_shape_arr) @@ -50,15 +55,15 @@ class ArrangeObjectsJob(Job): last_size = None grouped_operation = GroupedOperation() found_solution_for_all = True + not_fit_count = 0 for idx, (size, node, offset_shape_arr, hull_shape_arr) in enumerate(nodes_arr): # For performance reasons, we assume that when a location does not fit, # it will also not fit for the next object (while what can be untrue). - # We also skip possibilities by slicing through the possibilities (step = 10) if last_size == size: # This optimization works if many of the objects have the same size start_priority = last_priority else: start_priority = 0 - best_spot = arranger.bestSpot(offset_shape_arr, start_prio=start_priority, step=10) + best_spot = arranger.bestSpot(offset_shape_arr, start_prio=start_priority) x, y = best_spot.x, best_spot.y node.removeDecorator(ZOffsetDecorator) if node.getBoundingBox(): @@ -70,12 +75,12 @@ class ArrangeObjectsJob(Job): last_priority = best_spot.priority arranger.place(x, y, hull_shape_arr) # take place before the next one - grouped_operation.addOperation(TranslateOperation(node, Vector(x, center_y, y), set_position = True)) else: Logger.log("d", "Arrange all: could not find spot!") found_solution_for_all = False - grouped_operation.addOperation(TranslateOperation(node, Vector(200, center_y, - idx * 20), set_position = True)) + grouped_operation.addOperation(TranslateOperation(node, Vector(200, center_y, -not_fit_count * 20), set_position = True)) + not_fit_count += 1 status_message.setProgress((idx + 1) / len(nodes_arr) * 100) Job.yieldThread() diff --git a/cura/Arranging/ShapeArray.py b/cura/Arranging/ShapeArray.py index 68be9a6478..ab785cc3e1 100644 --- a/cura/Arranging/ShapeArray.py +++ b/cura/Arranging/ShapeArray.py @@ -74,7 +74,7 @@ class ShapeArray: # \param vertices @classmethod def arrayFromPolygon(cls, shape, vertices): - base_array = numpy.zeros(shape, dtype=float) # Initialize your array of zeros + base_array = numpy.zeros(shape, dtype = numpy.int32) # Initialize your array of zeros fill = numpy.ones(base_array.shape) * True # Initialize boolean array defining shape fill diff --git a/cura/AutoSave.py b/cura/AutoSave.py new file mode 100644 index 0000000000..1639868d6a --- /dev/null +++ b/cura/AutoSave.py @@ -0,0 +1,52 @@ +# Copyright (c) 2016 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from PyQt5.QtCore import QTimer + +from UM.Logger import Logger + + +class AutoSave: + def __init__(self, application): + self._application = application + self._application.getPreferences().preferenceChanged.connect(self._triggerTimer) + + self._global_stack = None + + self._application.getPreferences().addPreference("cura/autosave_delay", 1000 * 10) + + self._change_timer = QTimer() + self._change_timer.setInterval(self._application.getPreferences().getValue("cura/autosave_delay")) + self._change_timer.setSingleShot(True) + + self._saving = False + + def initialize(self): + # only initialise if the application is created and has started + self._change_timer.timeout.connect(self._onTimeout) + self._application.globalContainerStackChanged.connect(self._onGlobalStackChanged) + self._onGlobalStackChanged() + self._triggerTimer() + + def _triggerTimer(self, *args): + if not self._saving: + self._change_timer.start() + + def _onGlobalStackChanged(self): + if self._global_stack: + self._global_stack.propertyChanged.disconnect(self._triggerTimer) + self._global_stack.containersChanged.disconnect(self._triggerTimer) + + self._global_stack = self._application.getGlobalContainerStack() + + if self._global_stack: + self._global_stack.propertyChanged.connect(self._triggerTimer) + self._global_stack.containersChanged.connect(self._triggerTimer) + + def _onTimeout(self): + self._saving = True # To prevent the save process from triggering another autosave. + Logger.log("d", "Autosaving preferences, instances and profiles") + + self._application.saveSettings() + + self._saving = False diff --git a/cura/Backups/Backup.py b/cura/Backups/Backup.py new file mode 100644 index 0000000000..c4fe720b2b --- /dev/null +++ b/cura/Backups/Backup.py @@ -0,0 +1,155 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import io +import os +import re + +import shutil + +from typing import Optional +from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile + +from UM import i18nCatalog +from UM.Logger import Logger +from UM.Message import Message +from UM.Platform import Platform +from UM.Resources import Resources +from cura.CuraApplication import CuraApplication + + +class Backup: + """ + The backup class holds all data about a backup. + It is also responsible for reading and writing the zip file to the user data folder. + """ + + # These files should be ignored when making a backup. + IGNORED_FILES = [r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc"] + + # Re-use translation catalog. + catalog = i18nCatalog("cura") + + def __init__(self, zip_file: bytes = None, meta_data: dict = None): + self.zip_file = zip_file # type: Optional[bytes] + self.meta_data = meta_data # type: Optional[dict] + + def makeFromCurrent(self) -> (bool, Optional[str]): + """ + Create a backup from the current user config folder. + """ + cura_release = CuraApplication.getInstance().getVersion() + version_data_dir = Resources.getDataStoragePath() + + Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir) + + # Ensure all current settings are saved. + CuraApplication.getInstance().saveSettings() + + # We copy the preferences file to the user data directory in Linux as it's in a different location there. + # When restoring a backup on Linux, we move it back. + if Platform.isLinux(): + preferences_file_name = CuraApplication.getInstance().getApplicationName() + preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name)) + backup_preferences_file = os.path.join(version_data_dir, "{}.cfg".format(preferences_file_name)) + Logger.log("d", "Copying preferences file from %s to %s", preferences_file, backup_preferences_file) + shutil.copyfile(preferences_file, backup_preferences_file) + + # Create an empty buffer and write the archive to it. + buffer = io.BytesIO() + archive = self._makeArchive(buffer, version_data_dir) + files = archive.namelist() + + # Count the metadata items. We do this in a rather naive way at the moment. + machine_count = len([s for s in files if "machine_instances/" in s]) - 1 + material_count = len([s for s in files if "materials/" in s]) - 1 + profile_count = len([s for s in files if "quality_changes/" in s]) - 1 + plugin_count = len([s for s in files if "plugin.json" in s]) + + # Store the archive and metadata so the BackupManager can fetch them when needed. + self.zip_file = buffer.getvalue() + self.meta_data = { + "cura_release": cura_release, + "machine_count": str(machine_count), + "material_count": str(material_count), + "profile_count": str(profile_count), + "plugin_count": str(plugin_count) + } + + def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]: + """ + Make a full archive from the given root path with the given name. + :param root_path: The root directory to archive recursively. + :return: The archive as bytes. + """ + ignore_string = re.compile("|".join(self.IGNORED_FILES)) + try: + archive = ZipFile(buffer, "w", ZIP_DEFLATED) + for root, folders, files in os.walk(root_path): + for item_name in folders + files: + absolute_path = os.path.join(root, item_name) + if ignore_string.search(absolute_path): + continue + archive.write(absolute_path, absolute_path[len(root_path) + len(os.sep):]) + archive.close() + return archive + except (IOError, OSError, BadZipfile) as error: + Logger.log("e", "Could not create archive from user data directory: %s", error) + self._showMessage( + self.catalog.i18nc("@info:backup_failed", + "Could not create archive from user data directory: {}".format(error))) + return None + + def _showMessage(self, message: str) -> None: + """Show a UI message""" + Message(message, title=self.catalog.i18nc("@info:title", "Backup"), lifetime=30).show() + + def restore(self) -> bool: + """ + Restore this backups + :return: A boolean whether we had success or not. + """ + if not self.zip_file or not self.meta_data or not self.meta_data.get("cura_release", None): + # We can restore without the minimum required information. + Logger.log("w", "Tried to restore a Cura backup without having proper data or meta data.") + self._showMessage( + self.catalog.i18nc("@info:backup_failed", + "Tried to restore a Cura backup without having proper data or meta data.")) + return False + + current_version = CuraApplication.getInstance().getVersion() + version_to_restore = self.meta_data.get("cura_release", "master") + if current_version != version_to_restore: + # Cannot restore version older or newer than current because settings might have changed. + # Restoring this will cause a lot of issues so we don't allow this for now. + self._showMessage( + self.catalog.i18nc("@info:backup_failed", + "Tried to restore a Cura backup that does not match your current version.")) + return False + + version_data_dir = Resources.getDataStoragePath() + archive = ZipFile(io.BytesIO(self.zip_file), "r") + extracted = self._extractArchive(archive, version_data_dir) + + # Under Linux, preferences are stored elsewhere, so we copy the file to there. + if Platform.isLinux(): + preferences_file_name = CuraApplication.getInstance().getApplicationName() + preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name)) + backup_preferences_file = os.path.join(version_data_dir, "{}.cfg".format(preferences_file_name)) + Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file) + shutil.move(backup_preferences_file, preferences_file) + + return extracted + + @staticmethod + def _extractArchive(archive: "ZipFile", target_path: str) -> bool: + """ + Extract the whole archive to the given target path. + :param archive: The archive as ZipFile. + :param target_path: The target path. + :return: A boolean whether we had success or not. + """ + Logger.log("d", "Removing current data in location: %s", target_path) + Resources.factoryReset() + Logger.log("d", "Extracting backup to location: %s", target_path) + archive.extractall(target_path) + return True diff --git a/cura/Backups/BackupsManager.py b/cura/Backups/BackupsManager.py new file mode 100644 index 0000000000..fa75ddb587 --- /dev/null +++ b/cura/Backups/BackupsManager.py @@ -0,0 +1,56 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional + +from UM.Logger import Logger +from cura.Backups.Backup import Backup +from cura.CuraApplication import CuraApplication + + +class BackupsManager: + """ + The BackupsManager is responsible for managing the creating and restoring of backups. + Backups themselves are represented in a different class. + """ + def __init__(self): + self._application = CuraApplication.getInstance() + + def createBackup(self) -> (Optional[bytes], Optional[dict]): + """ + Get a backup of the current configuration. + :return: A Tuple containing a ZipFile (the actual backup) and a dict containing some meta data (like version). + """ + self._disableAutoSave() + backup = Backup() + backup.makeFromCurrent() + self._enableAutoSave() + # We don't return a Backup here because we want plugins only to interact with our API and not full objects. + return backup.zip_file, backup.meta_data + + def restoreBackup(self, zip_file: bytes, meta_data: dict) -> None: + """ + Restore a backup from a given ZipFile. + :param zip_file: A bytes object containing the actual backup. + :param meta_data: A dict containing some meta data that is needed to restore the backup correctly. + """ + if not meta_data.get("cura_release", None): + # If there is no "cura_release" specified in the meta data, we don't execute a backup restore. + Logger.log("w", "Tried to restore a backup without specifying a Cura version number.") + return + + self._disableAutoSave() + + backup = Backup(zip_file = zip_file, meta_data = meta_data) + restored = backup.restore() + if restored: + # At this point, Cura will need to restart for the changes to take effect. + # We don't want to store the data at this point as that would override the just-restored backup. + self._application.windowClosed(save_data=False) + + def _disableAutoSave(self): + """Here we try to disable the auto-save plugin as it might interfere with restoring a backup.""" + self._application.setSaveDataEnabled(False) + + def _enableAutoSave(self): + """Re-enable auto-save after we're done.""" + self._application.setSaveDataEnabled(True) diff --git a/cura/Backups/__init__.py b/cura/Backups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cura/BuildVolume.py b/cura/BuildVolume.py index f249c3513d..d0563a5352 100755 --- a/cura/BuildVolume.py +++ b/cura/BuildVolume.py @@ -1,12 +1,8 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -import math -from typing import List, Optional - -import numpy - -from PyQt5.QtCore import QTimer +from cura.Scene.CuraSceneNode import CuraSceneNode +from cura.Settings.ExtruderManager import ExtruderManager from UM.i18n import i18nCatalog from UM.Scene.Platform import Platform from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator @@ -20,14 +16,17 @@ from UM.Math.AxisAlignedBox import AxisAlignedBox from UM.Math.Polygon import Polygon from UM.Message import Message from UM.Signal import Signal +from PyQt5.QtCore import QTimer from UM.View.RenderBatch import RenderBatch from UM.View.GL.OpenGL import OpenGL - -from cura.Scene.CuraSceneNode import CuraSceneNode -from cura.Settings.ExtruderManager import ExtruderManager - catalog = i18nCatalog("cura") +import numpy +import math +import copy + +from typing import List, Optional + # Setting for clearance around the prime PRIME_CLEARANCE = 6.5 @@ -63,6 +62,7 @@ class BuildVolume(SceneNode): self._grid_shader = None self._disallowed_areas = [] + self._disallowed_areas_no_brim = [] self._disallowed_area_mesh = None self._error_areas = [] @@ -173,6 +173,9 @@ class BuildVolume(SceneNode): def getDisallowedAreas(self) -> List[Polygon]: return self._disallowed_areas + def getDisallowedAreasNoBrim(self) -> List[Polygon]: + return self._disallowed_areas_no_brim + def setDisallowedAreas(self, areas: List[Polygon]): self._disallowed_areas = areas @@ -457,7 +460,7 @@ class BuildVolume(SceneNode): minimum = Vector(min_w, min_h - 1.0, min_d), maximum = Vector(max_w, max_h - self._raft_thickness - self._extra_z_clearance, max_d)) - bed_adhesion_size = self._getEdgeDisallowedSize() + bed_adhesion_size = self.getEdgeDisallowedSize() # As this works better for UM machines, we only add the disallowed_area_size for the z direction. # This is probably wrong in all other cases. TODO! @@ -649,7 +652,7 @@ class BuildVolume(SceneNode): extruder_manager = ExtruderManager.getInstance() used_extruders = extruder_manager.getUsedExtruderStacks() - disallowed_border_size = self._getEdgeDisallowedSize() + disallowed_border_size = self.getEdgeDisallowedSize() if not used_extruders: # If no extruder is used, assume that the active extruder is used (else nothing is drawn) @@ -660,7 +663,8 @@ class BuildVolume(SceneNode): result_areas = self._computeDisallowedAreasStatic(disallowed_border_size, used_extruders) #Normal machine disallowed areas can always be added. prime_areas = self._computeDisallowedAreasPrimeBlob(disallowed_border_size, used_extruders) - prime_disallowed_areas = self._computeDisallowedAreasStatic(0, used_extruders) #Where the priming is not allowed to happen. This is not added to the result, just for collision checking. + result_areas_no_brim = self._computeDisallowedAreasStatic(0, used_extruders) #Where the priming is not allowed to happen. This is not added to the result, just for collision checking. + prime_disallowed_areas = copy.deepcopy(result_areas_no_brim) #Check if prime positions intersect with disallowed areas. for extruder in used_extruders: @@ -689,12 +693,15 @@ class BuildVolume(SceneNode): break result_areas[extruder_id].extend(prime_areas[extruder_id]) + result_areas_no_brim[extruder_id].extend(prime_areas[extruder_id]) nozzle_disallowed_areas = extruder.getProperty("nozzle_disallowed_areas", "value") for area in nozzle_disallowed_areas: polygon = Polygon(numpy.array(area, numpy.float32)) - polygon = polygon.getMinkowskiHull(Polygon.approximatedCircle(disallowed_border_size)) - result_areas[extruder_id].append(polygon) #Don't perform the offset on these. + polygon_disallowed_border = polygon.getMinkowskiHull(Polygon.approximatedCircle(disallowed_border_size)) + result_areas[extruder_id].append(polygon_disallowed_border) #Don't perform the offset on these. + #polygon_minimal_border = polygon.getMinkowskiHull(5) + result_areas_no_brim[extruder_id].append(polygon) # no brim # Add prime tower location as disallowed area. if len(used_extruders) > 1: #No prime tower in single-extrusion. @@ -710,6 +717,7 @@ class BuildVolume(SceneNode): break if not prime_tower_collision: result_areas[extruder_id].extend(prime_tower_areas[extruder_id]) + result_areas_no_brim[extruder_id].extend(prime_tower_areas[extruder_id]) else: self._error_areas.extend(prime_tower_areas[extruder_id]) @@ -718,6 +726,9 @@ class BuildVolume(SceneNode): self._disallowed_areas = [] for extruder_id in result_areas: self._disallowed_areas.extend(result_areas[extruder_id]) + self._disallowed_areas_no_brim = [] + for extruder_id in result_areas_no_brim: + self._disallowed_areas_no_brim.extend(result_areas_no_brim[extruder_id]) ## Computes the disallowed areas for objects that are printed with print # features. @@ -951,12 +962,12 @@ class BuildVolume(SceneNode): all_values[i] = 0 return all_values - ## Convenience function to calculate the disallowed radius around the edge. + ## Calculate the disallowed radius around the edge. # # This disallowed radius is to allow for space around the models that is # not part of the collision radius, such as bed adhesion (skirt/brim/raft) # and travel avoid distance. - def _getEdgeDisallowedSize(self): + def getEdgeDisallowedSize(self): if not self._global_container_stack or not self._global_container_stack.extruders: return 0 @@ -1037,6 +1048,6 @@ class BuildVolume(SceneNode): _prime_settings = ["extruder_prime_pos_x", "extruder_prime_pos_y", "extruder_prime_pos_z", "prime_blob_enable"] _tower_settings = ["prime_tower_enable", "prime_tower_circular", "prime_tower_size", "prime_tower_position_x", "prime_tower_position_y"] _ooze_shield_settings = ["ooze_shield_enabled", "ooze_shield_dist"] - _distance_settings = ["infill_wipe_dist", "travel_avoid_distance", "support_offset", "support_enable", "travel_avoid_other_parts"] + _distance_settings = ["infill_wipe_dist", "travel_avoid_distance", "support_offset", "support_enable", "travel_avoid_other_parts", "travel_avoid_supports"] _extruder_settings = ["support_enable", "support_bottom_enable", "support_roof_enable", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "brim_line_count", "adhesion_extruder_nr", "adhesion_type"] #Settings that can affect which extruders are used. _limit_to_extruder_settings = ["wall_extruder_nr", "wall_0_extruder_nr", "wall_x_extruder_nr", "top_bottom_extruder_nr", "infill_extruder_nr", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "adhesion_extruder_nr"] diff --git a/cura/CuraActions.py b/cura/CuraActions.py index 019893957f..8544438f3a 100644 --- a/cura/CuraActions.py +++ b/cura/CuraActions.py @@ -72,7 +72,8 @@ class CuraActions(QObject): # \param count The number of times to multiply the selection. @pyqtSlot(int) def multiplySelection(self, count: int) -> None: - job = MultiplyObjectsJob(Selection.getAllSelectedObjects(), count, min_offset = 8) + min_offset = Application.getInstance().getBuildVolume().getEdgeDisallowedSize() + 2 # Allow for some rounding errors + job = MultiplyObjectsJob(Selection.getAllSelectedObjects(), count, min_offset = max(min_offset, 8)) job.start() ## Delete all selected objects. diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index 80cf7a3d74..70c4d7727a 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -85,6 +85,7 @@ from cura.Settings.SimpleModeSettingsManager import SimpleModeSettingsManager from cura.Machines.VariantManager import VariantManager from .SingleInstance import SingleInstance +from .AutoSave import AutoSave from . import PlatformPhysics from . import BuildVolume from . import CameraAnimation @@ -154,9 +155,6 @@ class CuraApplication(QtApplication): self._boot_loading_time = time.time() - self._currently_loading_files = [] - self._non_sliceable_extensions = [] - # Variables set from CLI self._files_to_open = [] self._use_single_instance = False @@ -223,6 +221,10 @@ class CuraApplication(QtApplication): self._need_to_show_user_agreement = True + # Backups + self._auto_save = None + self._save_data_enabled = True + from cura.Settings.CuraContainerRegistry import CuraContainerRegistry self._container_registry_class = CuraContainerRegistry @@ -469,6 +471,7 @@ class CuraApplication(QtApplication): preferences.addPreference("cura/categories_expanded", "") preferences.addPreference("cura/jobname_prefix", True) + preferences.addPreference("cura/select_models_on_load", False) preferences.addPreference("view/center_on_select", False) preferences.addPreference("mesh/scale_to_fit", False) preferences.addPreference("mesh/scale_tiny_meshes", True) @@ -585,14 +588,17 @@ class CuraApplication(QtApplication): showPrintMonitor = pyqtSignal(bool, arguments = ["show"]) - ## Cura has multiple locations where instance containers need to be saved, so we need to handle this differently. - # - # Note that the AutoSave plugin also calls this method. - def saveSettings(self): - if not self.started: # Do not do saving during application start - return + def setSaveDataEnabled(self, enabled: bool) -> None: + self._save_data_enabled = enabled + # Cura has multiple locations where instance containers need to be saved, so we need to handle this differently. + def saveSettings(self): + if not self.started or not self._save_data_enabled: + # Do not do saving during application start or when data should not be safed on quit. + return ContainerRegistry.getInstance().saveDirtyContainers() + Preferences.getInstance().writeToFile(Resources.getStoragePath(Resources.Preferences, + self._application_name + ".cfg")) def saveStack(self, stack): ContainerRegistry.getInstance().saveContainer(stack) @@ -695,6 +701,9 @@ class CuraApplication(QtApplication): self._post_start_timer.timeout.connect(self._onPostStart) self._post_start_timer.start() + self._auto_save = AutoSave(self) + self._auto_save.initialize() + self.exec_() def __setUpSingleInstanceServer(self): @@ -844,6 +853,9 @@ class CuraApplication(QtApplication): return super().event(event) + def getAutoSave(self): + return self._auto_save + ## Get print information (duration / material used) def getPrintInformation(self): return self._print_information @@ -1228,34 +1240,12 @@ class CuraApplication(QtApplication): nodes.append(node) self.arrange(nodes, fixed_nodes = []) - ## Arrange Selection - @pyqtSlot() - def arrangeSelection(self): - nodes = Selection.getAllSelectedObjects() - - # What nodes are on the build plate and are not being moved - fixed_nodes = [] - for node in DepthFirstIterator(self.getController().getScene().getRoot()): - if not isinstance(node, SceneNode): - continue - if not node.getMeshData() and not node.callDecoration("isGroup"): - continue # Node that doesnt have a mesh and is not a group. - if node.getParent() and node.getParent().callDecoration("isGroup"): - continue # Grouped nodes don't need resetting as their parent (the group) is resetted) - if not node.isSelectable(): - continue # i.e. node with layer data - if not node.callDecoration("isSliceable") and not node.callDecoration("isGroup"): - continue # i.e. node with layer data - if node in nodes: # exclude selected node from fixed_nodes - continue - fixed_nodes.append(node) - self.arrange(nodes, fixed_nodes) - ## Arrange a set of nodes given a set of fixed nodes # \param nodes nodes that we have to place # \param fixed_nodes nodes that are placed in the arranger before finding spots for nodes def arrange(self, nodes, fixed_nodes): - job = ArrangeObjectsJob(nodes, fixed_nodes) + min_offset = self.getBuildVolume().getEdgeDisallowedSize() + 2 # Allow for some rounding errors + job = ArrangeObjectsJob(nodes, fixed_nodes, min_offset = max(min_offset, 8)) job.start() ## Reload all mesh data on the screen from file. @@ -1539,6 +1529,9 @@ class CuraApplication(QtApplication): self.callLater(self.openProjectFile.emit, file) return + if Preferences.getInstance().getValue("cura/select_models_on_load"): + Selection.clear() + f = file.toLocalFile() extension = os.path.splitext(f)[1] filename = os.path.basename(f) @@ -1585,11 +1578,16 @@ class CuraApplication(QtApplication): for node_ in DepthFirstIterator(root): if node_.callDecoration("isSliceable") and node_.callDecoration("getBuildPlateNumber") == target_build_plate: fixed_nodes.append(node_) - arranger = Arrange.create(fixed_nodes = fixed_nodes) + global_container_stack = self.getGlobalContainerStack() + machine_width = global_container_stack.getProperty("machine_width", "value") + machine_depth = global_container_stack.getProperty("machine_depth", "value") + arranger = Arrange.create(x = machine_width, y = machine_depth, fixed_nodes = fixed_nodes) min_offset = 8 default_extruder_position = self.getMachineManager().defaultExtruderPosition default_extruder_id = self._global_container_stack.extruders[default_extruder_position].getId() + select_models_on_load = Preferences.getInstance().getValue("cura/select_models_on_load") + for original_node in nodes: # Create a CuraSceneNode just if the original node is not that type @@ -1603,7 +1601,6 @@ class CuraApplication(QtApplication): if(original_node.getScale() != Vector(1.0, 1.0, 1.0)): node.scale(original_node.getScale()) - node.setSelectable(True) node.setName(os.path.basename(filename)) self.getBuildVolume().checkBoundsAndUpdate(node) @@ -1663,6 +1660,9 @@ class CuraApplication(QtApplication): node.callDecoration("setActiveExtruder", default_extruder_id) scene.sceneChanged.emit(node) + if select_models_on_load: + Selection.add(node) + self.fileCompleted.emit(filename) def addNonSliceableExtension(self, extension): diff --git a/cura/MultiplyObjectsJob.py b/cura/MultiplyObjectsJob.py index 3444da249f..46f7f56f8a 100644 --- a/cura/MultiplyObjectsJob.py +++ b/cura/MultiplyObjectsJob.py @@ -30,11 +30,18 @@ class MultiplyObjectsJob(Job): total_progress = len(self._objects) * self._count current_progress = 0 + global_container_stack = Application.getInstance().getGlobalContainerStack() + machine_width = global_container_stack.getProperty("machine_width", "value") + machine_depth = global_container_stack.getProperty("machine_depth", "value") + root = scene.getRoot() - arranger = Arrange.create(scene_root=root) + scale = 0.5 + arranger = Arrange.create(x = machine_width, y = machine_depth, scene_root = root, scale = scale) processed_nodes = [] nodes = [] + not_fit_count = 0 + for node in self._objects: # If object is part of a group, multiply group current_node = node @@ -46,12 +53,13 @@ class MultiplyObjectsJob(Job): processed_nodes.append(current_node) node_too_big = False - if node.getBoundingBox().width < 300 or node.getBoundingBox().depth < 300: - offset_shape_arr, hull_shape_arr = ShapeArray.fromNode(current_node, min_offset=self._min_offset) + if node.getBoundingBox().width < machine_width or node.getBoundingBox().depth < machine_depth: + offset_shape_arr, hull_shape_arr = ShapeArray.fromNode(current_node, min_offset = self._min_offset, scale = scale) else: node_too_big = True found_solution_for_all = True + arranger.resetLastPriority() for i in range(self._count): # We do place the nodes one by one, as we want to yield in between. if not node_too_big: @@ -59,8 +67,9 @@ class MultiplyObjectsJob(Job): if node_too_big or not solution_found: found_solution_for_all = False new_location = new_node.getPosition() - new_location = new_location.set(z = 100 - i * 20) + new_location = new_location.set(z = - not_fit_count * 20) new_node.setPosition(new_location) + not_fit_count += 1 # Same build plate build_plate_number = current_node.callDecoration("getBuildPlateNumber") diff --git a/cura/PreviewPass.py b/cura/PreviewPass.py index 4241a2f243..436e2719b7 100644 --- a/cura/PreviewPass.py +++ b/cura/PreviewPass.py @@ -79,10 +79,10 @@ class PreviewPass(RenderPass): for node in DepthFirstIterator(self._scene.getRoot()): if node.callDecoration("isSliceable") and node.getMeshData() and node.isVisible(): per_mesh_stack = node.callDecoration("getStack") - if node.callDecoration("isNonPrintingMesh"): + if node.callDecoration("isNonThumbnailVisibleMesh"): # Non printing mesh continue - elif per_mesh_stack is not None and per_mesh_stack.getProperty("support_mesh", "value") == True: + elif per_mesh_stack is not None and per_mesh_stack.getProperty("support_mesh", "value"): # Support mesh uniforms = {} shade_factor = 0.6 @@ -112,4 +112,3 @@ class PreviewPass(RenderPass): batch_support_mesh.render(render_camera) self.release() - diff --git a/cura/PrintInformation.py b/cura/PrintInformation.py index 917085badc..39882ce947 100644 --- a/cura/PrintInformation.py +++ b/cura/PrintInformation.py @@ -279,9 +279,12 @@ class PrintInformation(QObject): for build_plate_number in range(self._multi_build_plate_model.maxBuildPlate + 1): self._calculateInformation(build_plate_number) + # Manual override of job name should also set the base name so that when the printer prefix is updated, it the + # prefix can be added to the manually added name, not the old base name @pyqtSlot(str) def setJobName(self, name): self._job_name = name + self._base_name = name.replace(self._abbr_machine + "_", "") self.jobNameChanged.emit() jobNameChanged = pyqtSignal() diff --git a/cura/Settings/SettingOverrideDecorator.py b/cura/Settings/SettingOverrideDecorator.py index 69d98314b6..a662027d8f 100644 --- a/cura/Settings/SettingOverrideDecorator.py +++ b/cura/Settings/SettingOverrideDecorator.py @@ -30,6 +30,7 @@ class SettingOverrideDecorator(SceneNodeDecorator): # Note that Support Mesh is not in here because it actually generates # g-code in the volume of the mesh. _non_printing_mesh_settings = {"anti_overhang_mesh", "infill_mesh", "cutting_mesh"} + _non_thumbnail_visible_settings = {"anti_overhang_mesh", "infill_mesh", "cutting_mesh", "support_mesh"} def __init__(self): super().__init__() @@ -41,6 +42,7 @@ class SettingOverrideDecorator(SceneNodeDecorator): self._extruder_stack = ExtruderManager.getInstance().getExtruderStack(0).getId() self._is_non_printing_mesh = False + self._is_non_thumbnail_visible_mesh = False self._stack.propertyChanged.connect(self._onSettingChanged) @@ -72,6 +74,7 @@ class SettingOverrideDecorator(SceneNodeDecorator): # use value from the stack because there can be a delay in signal triggering and "_is_non_printing_mesh" # has not been updated yet. deep_copy._is_non_printing_mesh = self.evaluateIsNonPrintingMesh() + deep_copy._is_non_thumbnail_visible_mesh = self.evaluateIsNonThumbnailVisibleMesh() return deep_copy @@ -102,10 +105,17 @@ class SettingOverrideDecorator(SceneNodeDecorator): def evaluateIsNonPrintingMesh(self): return any(bool(self._stack.getProperty(setting, "value")) for setting in self._non_printing_mesh_settings) + def isNonThumbnailVisibleMesh(self): + return self._is_non_thumbnail_visible_mesh + + def evaluateIsNonThumbnailVisibleMesh(self): + return any(bool(self._stack.getProperty(setting, "value")) for setting in self._non_thumbnail_visible_settings) + def _onSettingChanged(self, instance, property_name): # Reminder: 'property' is a built-in function if property_name == "value": # Trigger slice/need slicing if the value has changed. self._is_non_printing_mesh = self.evaluateIsNonPrintingMesh() + self._is_non_thumbnail_visible_mesh = self.evaluateIsNonThumbnailVisibleMesh() Application.getInstance().getBackend().needsSlicing() Application.getInstance().getBackend().tickle() diff --git a/cura/Snapshot.py b/cura/Snapshot.py index d1bfeea40f..b730c1fdcf 100644 --- a/cura/Snapshot.py +++ b/cura/Snapshot.py @@ -48,7 +48,7 @@ class Snapshot: # determine zoom and look at bbox = None for node in DepthFirstIterator(root): - if node.callDecoration("isSliceable") and node.getMeshData() and node.isVisible() and not node.callDecoration("isNonPrintingMesh"): + if node.callDecoration("isSliceable") and node.getMeshData() and node.isVisible() and not node.callDecoration("isNonThumbnailVisibleMesh"): if bbox is None: bbox = node.getBoundingBox() else: diff --git a/cura/Stages/CuraStage.py b/cura/Stages/CuraStage.py index 8b7822ed7a..b2f6d61799 100644 --- a/cura/Stages/CuraStage.py +++ b/cura/Stages/CuraStage.py @@ -1,9 +1,10 @@ # Copyright (c) 2017 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from PyQt5.QtCore import pyqtProperty, QUrl, QObject +from PyQt5.QtCore import pyqtProperty, QUrl from UM.Stage import Stage + class CuraStage(Stage): def __init__(self, parent = None): diff --git a/plugins/AutoSave/AutoSave.py b/plugins/AutoSave/AutoSave.py deleted file mode 100644 index 5025cc563c..0000000000 --- a/plugins/AutoSave/AutoSave.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (c) 2016 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. - -from PyQt5.QtCore import QTimer - -from UM.Extension import Extension -from UM.Application import Application -from UM.Resources import Resources -from UM.Logger import Logger - - -class AutoSave(Extension): - def __init__(self): - super().__init__() - - Application.getInstance().getPreferences().preferenceChanged.connect(self._triggerTimer) - - self._global_stack = None - - Application.getInstance().getPreferences().addPreference("cura/autosave_delay", 1000 * 10) - - self._change_timer = QTimer() - self._change_timer.setInterval(Application.getInstance().getPreferences().getValue("cura/autosave_delay")) - self._change_timer.setSingleShot(True) - - self._saving = False - - # At this point, the Application instance has not finished its constructor call yet, so directly using something - # like Application.getInstance() is not correct. The initialisation now will only gets triggered after the - # application finishes its start up successfully. - self._init_timer = QTimer() - self._init_timer.setInterval(1000) - self._init_timer.setSingleShot(True) - self._init_timer.timeout.connect(self.initialize) - self._init_timer.start() - - def initialize(self): - # only initialise if the application is created and has started - from cura.CuraApplication import CuraApplication - if not CuraApplication.Created: - self._init_timer.start() - return - if not CuraApplication.getInstance().started: - self._init_timer.start() - return - - self._change_timer.timeout.connect(self._onTimeout) - Application.getInstance().globalContainerStackChanged.connect(self._onGlobalStackChanged) - self._onGlobalStackChanged() - - self._triggerTimer() - - def _triggerTimer(self, *args): - if not self._saving: - self._change_timer.start() - - def _onGlobalStackChanged(self): - if self._global_stack: - self._global_stack.propertyChanged.disconnect(self._triggerTimer) - self._global_stack.containersChanged.disconnect(self._triggerTimer) - - self._global_stack = Application.getInstance().getGlobalContainerStack() - - if self._global_stack: - self._global_stack.propertyChanged.connect(self._triggerTimer) - self._global_stack.containersChanged.connect(self._triggerTimer) - - def _onTimeout(self): - self._saving = True # To prevent the save process from triggering another autosave. - Logger.log("d", "Autosaving preferences, instances and profiles") - - Application.getInstance().saveSettings() - - Application.getInstance().getPreferences().writeToFile(Resources.getStoragePath(Resources.Preferences, Application.getInstance().getApplicationName() + ".cfg")) - - self._saving = False diff --git a/plugins/AutoSave/__init__.py b/plugins/AutoSave/__init__.py deleted file mode 100644 index d7ee0736a2..0000000000 --- a/plugins/AutoSave/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) 2016 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. - -from . import AutoSave - -from UM.i18n import i18nCatalog -catalog = i18nCatalog("cura") - -def getMetaData(): - return {} - -def register(app): - return { "extension": AutoSave.AutoSave() } diff --git a/plugins/AutoSave/plugin.json b/plugins/AutoSave/plugin.json deleted file mode 100644 index 32e07a1062..0000000000 --- a/plugins/AutoSave/plugin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "Auto Save", - "author": "Ultimaker B.V.", - "version": "1.0.0", - "description": "Automatically saves Preferences, Machines and Profiles after changes.", - "api": 4, - "i18n-catalog": "cura" -} diff --git a/plugins/GCodeGzWriter/__init__.py b/plugins/GCodeGzWriter/__init__.py index a4d576aef6..e257bcb011 100644 --- a/plugins/GCodeGzWriter/__init__.py +++ b/plugins/GCodeGzWriter/__init__.py @@ -9,7 +9,7 @@ from . import GCodeGzWriter catalog = i18nCatalog("cura") def getMetaData(): - file_extension = "gz" if Platform.isOSX() else "gcode.gz" + file_extension = "gcode.gz" return { "mesh_writer": { "output": [{ diff --git a/plugins/MonitorStage/MonitorStage.py b/plugins/MonitorStage/MonitorStage.py index 931c205fff..ace201e994 100644 --- a/plugins/MonitorStage/MonitorStage.py +++ b/plugins/MonitorStage/MonitorStage.py @@ -69,10 +69,11 @@ class MonitorStage(CuraStage): self._updateSidebar() def _updateMainOverlay(self): - main_component_path = os.path.join(PluginRegistry.getInstance().getPluginPath("MonitorStage"), "MonitorMainView.qml") + main_component_path = os.path.join(PluginRegistry.getInstance().getPluginPath("MonitorStage"), + "MonitorMainView.qml") self.addDisplayComponent("main", main_component_path) def _updateSidebar(self): - # TODO: currently the sidebar component for prepare and monitor stages is the same, this will change with the printer output device refactor! - sidebar_component_path = os.path.join(Resources.getPath(Application.getInstance().ResourceTypes.QmlFiles), "Sidebar.qml") + sidebar_component_path = os.path.join(Resources.getPath(Application.getInstance().ResourceTypes.QmlFiles), + "MonitorSidebar.qml") self.addDisplayComponent("sidebar", sidebar_component_path) diff --git a/plugins/PostProcessingPlugin/scripts/PauseAtHeight.py b/plugins/PostProcessingPlugin/scripts/PauseAtHeight.py index 805ab0a2c3..cb31514e9d 100644 --- a/plugins/PostProcessingPlugin/scripts/PauseAtHeight.py +++ b/plugins/PostProcessingPlugin/scripts/PauseAtHeight.py @@ -117,12 +117,21 @@ class PauseAtHeight(Script): } }""" + def getNextXY(self, layer: str): + """ + 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 + return 0, 0 + def execute(self, data: list): - """data is a list. Each index contains a layer""" - - x = 0. - y = 0. pause_at = self.getSettingValueByKey("pause_at") pause_height = self.getSettingValueByKey("pause_height") pause_layer = self.getSettingValueByKey("pause_layer") @@ -138,73 +147,94 @@ class PauseAtHeight(Script): resume_temperature = self.getSettingValueByKey("resume_temperature") # T = ExtruderManager.getInstance().getActiveExtruderStack().getProperty("material_print_temperature", "value") - # with open("out.txt", "w") as f: - # f.write(T) # use offset to calculate the current height: = - layer_0_z = 0. current_z = 0 got_first_g_cmd_on_layer_0 = False + + nbr_negative_layers = 0 + for index, layer in enumerate(data): lines = layer.split("\n") + + # Scroll each line of instruction for each layer in the G-code for line in lines: + # Fist positive layer reached if ";LAYER:0" in line: layers_started = True + # Count nbr of negative layers (raft) + elif ";LAYER:-" in line: + nbr_negative_layers += 1 if not layers_started: continue + # If a Z instruction is in the line, read the current Z if self.getValue(line, "Z") is not None: current_z = self.getValue(line, "Z") if pause_at == "height": + # Ignore if the line is not G1 or G0 if self.getValue(line, "G") != 1 and self.getValue(line, "G") != 0: continue + # This block is executed once, the first time there is a G + # command, to get the z offset (z for first positive layer) if not got_first_g_cmd_on_layer_0: layer_0_z = current_z got_first_g_cmd_on_layer_0 = True - x = self.getValue(line, "X", x) - y = self.getValue(line, "Y", y) - current_height = current_z - layer_0_z + if current_height < pause_height: - break #Try the next layer. - else: #Pause at layer. + break # Try the next layer. + + # Pause at layer + else: if not line.startswith(";LAYER:"): continue current_layer = line[len(";LAYER:"):] try: current_layer = int(current_layer) - except ValueError: #Couldn't cast to int. Something is wrong with this g-code data. - continue - if current_layer < pause_layer: - break #Try the next layer. - prevLayer = data[index - 1] - prevLines = prevLayer.split("\n") + # Couldn't cast to int. Something is wrong with this + # g-code data + except ValueError: + continue + if current_layer < pause_layer - nbr_negative_layers: + continue + + # Get X and Y from the next layer (better position for + # the nozzle) + next_layer = data[index + 1] + x, y = self.getNextXY(next_layer) + + prev_layer = data[index - 1] + prev_lines = prev_layer.split("\n") current_e = 0. # Access last layer, browse it backwards to find # last extruder absolute position - for prevLine in reversed(prevLines): + for prevLine in reversed(prev_lines): current_e = self.getValue(prevLine, "E", -1) if current_e >= 0: break # include a number of previous layers for i in range(1, redo_layers + 1): - prevLayer = data[index - i] - layer = prevLayer + layer + prev_layer = data[index - i] + layer = prev_layer + layer # Get extruder's absolute position at the - # begining of the first layer redone + # beginning of the first layer redone # see https://github.com/nallath/PostProcessingPlugin/issues/55 if i == redo_layers: - prevLines = prevLayer.split("\n") - for line in prevLines: + # Get X and Y from the next layer (better position for + # the nozzle) + x, y = self.getNextXY(layer) + prev_lines = prev_layer.split("\n") + for line in prev_lines: new_e = self.getValue(line, 'E', current_e) - if new_e != current_e: current_e = new_e break @@ -213,61 +243,63 @@ class PauseAtHeight(Script): prepend_gcode += ";added code by post processing\n" prepend_gcode += ";script: PauseAtHeight.py\n" if pause_at == "height": - prepend_gcode += ";current z: {z}\n".format(z = current_z) - prepend_gcode += ";current height: {height}\n".format(height = current_height) + prepend_gcode += ";current z: {z}\n".format(z=current_z) + prepend_gcode += ";current height: {height}\n".format(height=current_height) else: - prepend_gcode += ";current layer: {layer}\n".format(layer = current_layer) + prepend_gcode += ";current layer: {layer}\n".format(layer=current_layer) # Retraction - prepend_gcode += self.putValue(M = 83) + "\n" + prepend_gcode += self.putValue(M=83) + "\n" if retraction_amount != 0: - prepend_gcode += self.putValue(G = 1, E = -retraction_amount, F = retraction_speed * 60) + "\n" + prepend_gcode += self.putValue(G=1, E=-retraction_amount, F=retraction_speed * 60) + "\n" # Move the head away - prepend_gcode += self.putValue(G = 1, Z = current_z + 1, F = 300) + "\n" - prepend_gcode += self.putValue(G = 1, X = park_x, Y = park_y, F = 9000) + "\n" + prepend_gcode += self.putValue(G=1, Z=current_z + 1, F=300) + "\n" + + # This line should be ok + prepend_gcode += self.putValue(G=1, X=park_x, Y=park_y, F=9000) + "\n" + if current_z < 15: - prepend_gcode += self.putValue(G = 1, Z = 15, F = 300) + "\n" + prepend_gcode += self.putValue(G=1, Z=15, F=300) + "\n" # Disable the E steppers - prepend_gcode += self.putValue(M = 84, E = 0) + "\n" + prepend_gcode += self.putValue(M=84, E=0) + "\n" # Set extruder standby temperature - prepend_gcode += self.putValue(M = 104, S = standby_temperature) + "; standby temperature\n" + prepend_gcode += self.putValue(M=104, S=standby_temperature) + "; standby temperature\n" # Wait till the user continues printing - prepend_gcode += self.putValue(M = 0) + ";Do the actual pause\n" + prepend_gcode += self.putValue(M=0) + ";Do the actual pause\n" # Set extruder resume temperature - prepend_gcode += self.putValue(M = 109, S = resume_temperature) + "; resume temperature\n" + prepend_gcode += self.putValue(M=109, S=resume_temperature) + "; resume temperature\n" # Push the filament back, if retraction_amount != 0: - prepend_gcode += self.putValue(G = 1, E = retraction_amount, F = retraction_speed * 60) + "\n" + prepend_gcode += self.putValue(G=1, E=retraction_amount, F=retraction_speed * 60) + "\n" # Optionally extrude material if extrude_amount != 0: - prepend_gcode += self.putValue(G = 1, E = extrude_amount, F = extrude_speed * 60) + "\n" + prepend_gcode += self.putValue(G=1, E=extrude_amount, F=extrude_speed * 60) + "\n" # and retract again, the properly primes the nozzle # when changing filament. if retraction_amount != 0: - prepend_gcode += self.putValue(G = 1, E = -retraction_amount, F = retraction_speed * 60) + "\n" + prepend_gcode += self.putValue(G=1, E=-retraction_amount, F=retraction_speed * 60) + "\n" # Move the head back - prepend_gcode += self.putValue(G = 1, Z = current_z + 1, F = 300) + "\n" - prepend_gcode += self.putValue(G = 1, X = x, Y = y, F = 9000) + "\n" + prepend_gcode += self.putValue(G=1, Z=current_z + 1, F=300) + "\n" + prepend_gcode += self.putValue(G=1, X=x, Y=y, F=9000) + "\n" if retraction_amount != 0: - prepend_gcode += self.putValue(G = 1, E = retraction_amount, F = retraction_speed * 60) + "\n" - prepend_gcode += self.putValue(G = 1, F = 9000) + "\n" - prepend_gcode += self.putValue(M = 82) + "\n" + prepend_gcode += self.putValue(G=1, E=retraction_amount, F=retraction_speed * 60) + "\n" + prepend_gcode += self.putValue(G=1, F=9000) + "\n" + prepend_gcode += self.putValue(M=82) + "\n" # reset extrude value to pre pause value - prepend_gcode += self.putValue(G = 92, E = current_e) + "\n" + prepend_gcode += self.putValue(G=92, E=current_e) + "\n" layer = prepend_gcode + layer - # Override the data of this layer with the # modified data data[index] = layer diff --git a/plugins/PrepareStage/PrepareStage.py b/plugins/PrepareStage/PrepareStage.py index 9d4d632845..c3c9f0a1f8 100644 --- a/plugins/PrepareStage/PrepareStage.py +++ b/plugins/PrepareStage/PrepareStage.py @@ -14,5 +14,6 @@ class PrepareStage(CuraStage): Application.getInstance().engineCreatedSignal.connect(self._engineCreated) def _engineCreated(self): - sidebar_component_path = os.path.join(Resources.getPath(Application.getInstance().ResourceTypes.QmlFiles), "Sidebar.qml") + sidebar_component_path = os.path.join(Resources.getPath(Application.getInstance().ResourceTypes.QmlFiles), + "PrepareSidebar.qml") self.addDisplayComponent("sidebar", sidebar_component_path) diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 776f2a3870..6dac823f06 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -24,17 +24,26 @@ from .PackagesModel import PackagesModel i18n_catalog = i18nCatalog("cura") + ## The Toolbox class is responsible of communicating with the server through the API class Toolbox(QObject, Extension): + + DEFAULT_PACKAGES_API_ROOT = "https://api.ultimaker.com" + def __init__(self, parent=None) -> None: super().__init__(parent) self._application = Application.getInstance() self._package_manager = None self._plugin_registry = Application.getInstance().getPluginRegistry() + self._packages_api_root = self._getPackagesApiRoot() self._packages_version = self._getPackagesVersion() self._api_version = 1 - self._api_url = "https://api.ultimaker.com/cura-packages/v{api_version}/cura/v{package_version}".format( api_version = self._api_version, package_version = self._packages_version) + self._api_url = "{api_root}/cura-packages/v{api_version}/cura/v{package_version}".format( + api_root = self._packages_api_root, + api_version = self._api_version, + package_version = self._packages_version + ) # Network: self._get_packages_request = None @@ -153,6 +162,15 @@ class Toolbox(QObject, Extension): def _onAppInitialized(self) -> None: self._package_manager = Application.getInstance().getCuraPackageManager() + # Get the API root for the packages API depending on Cura version settings. + def _getPackagesApiRoot(self) -> str: + if not hasattr(cura, "CuraVersion"): + return self.DEFAULT_PACKAGES_API_ROOT + if not hasattr(cura.CuraVersion, "CuraPackagesApiRoot"): + return self.DEFAULT_PACKAGES_API_ROOT + return cura.CuraVersion.CuraPackagesApiRoot + + # Get the packages version depending on Cura version settings. def _getPackagesVersion(self) -> int: if not hasattr(cura, "CuraVersion"): return self._plugin_registry.APIVersion diff --git a/plugins/USBPrinting/USBPrinterOutputDevice.py b/plugins/USBPrinting/USBPrinterOutputDevice.py index 7070ad7c3f..b2ca5562e3 100644 --- a/plugins/USBPrinting/USBPrinterOutputDevice.py +++ b/plugins/USBPrinting/USBPrinterOutputDevice.py @@ -308,7 +308,21 @@ class USBPrinterOutputDevice(PrinterOutputDevice): if b"ok T:" in line or line.startswith(b"T:") or b"ok B:" in line or line.startswith(b"B:"): # Temperature message. 'T:' for extruder and 'B:' for bed extruder_temperature_matches = re.findall(b"T(\d*): ?([\d\.]+) ?\/?([\d\.]+)?", line) # Update all temperature values - for match, extruder in zip(extruder_temperature_matches, self._printers[0].extruders): + matched_extruder_nrs = [] + for match in extruder_temperature_matches: + extruder_nr = 0 + if match[0] != b"": + extruder_nr = int(match[0]) + + if extruder_nr in matched_extruder_nrs: + continue + matched_extruder_nrs.append(extruder_nr) + + if extruder_nr >= len(self._printers[0].extruders): + Logger.log("w", "Printer reports more temperatures than the number of configured extruders") + continue + + extruder = self._printers[0].extruders[extruder_nr] if match[1]: extruder.updateHotendTemperature(float(match[1])) if match[2]: diff --git a/plugins/USBPrinting/USBPrinterOutputDeviceManager.py b/plugins/USBPrinting/USBPrinterOutputDeviceManager.py index 5fc657e50f..abf3b9ece2 100644 --- a/plugins/USBPrinting/USBPrinterOutputDeviceManager.py +++ b/plugins/USBPrinting/USBPrinterOutputDeviceManager.py @@ -60,7 +60,7 @@ class USBPrinterOutputDeviceManager(QObject, OutputDevicePlugin): self._check_updates = True self._update_thread.start() - def stop(self): + def stop(self, store_data: bool = True): self._check_updates = False def _onConnectionStateChanged(self, serial_port): @@ -79,10 +79,11 @@ class USBPrinterOutputDeviceManager(QObject, OutputDevicePlugin): if container_stack is None: time.sleep(5) continue + port_list = [] # Just an empty list; all USB devices will be removed. if container_stack.getMetaDataEntry("supports_usb_connection"): - port_list = self.getSerialPortList(only_list_usb=True) - else: - port_list = [] # Just use an empty list; all USB devices will be removed. + machine_file_formats = [file_type.strip() for file_type in container_stack.getMetaDataEntry("file_formats").split(";")] + if "text/x-gcode" in machine_file_formats: + port_list = self.getSerialPortList(only_list_usb=True) self._addRemovePorts(port_list) time.sleep(5) diff --git a/resources/bundled_packages.json b/resources/bundled_packages.json index 8d58f226b0..a63d08ddab 100644 --- a/resources/bundled_packages.json +++ b/resources/bundled_packages.json @@ -33,23 +33,6 @@ } } }, - "AutoSave": { - "package_info": { - "package_id": "AutoSave", - "package_type": "plugin", - "display_name": "Auto-Save", - "description": "Automatically saves Preferences, Machines and Profiles after changes.", - "package_version": "1.0.0", - "cura_version": 4, - "website": "https://ultimaker.com", - "author": { - "author_id": "Ultimaker", - "display_name": "Ultimaker B.V.", - "email": "plugins@ultimaker.com", - "website": "https://ultimaker.com" - } - } - }, "ChangeLogPlugin": { "package_info": { "package_id": "ChangeLogPlugin", diff --git a/resources/definitions/fdmprinter.def.json b/resources/definitions/fdmprinter.def.json index fc8395a8dc..0d6c1eaa7b 100644 --- a/resources/definitions/fdmprinter.def.json +++ b/resources/definitions/fdmprinter.def.json @@ -3324,6 +3324,16 @@ "settable_per_mesh": false, "settable_per_extruder": true }, + "travel_avoid_supports": + { + "label": "Avoid Supports When Traveling", + "description": "The nozzle avoids already printed supports when traveling. This option is only available when combing is enabled.", + "type": "bool", + "default_value": false, + "enabled": "resolveOrValue('retraction_combing') != 'off' and travel_avoid_other_parts", + "settable_per_mesh": false, + "settable_per_extruder": true + }, "travel_avoid_distance": { "label": "Travel Avoid Distance", diff --git a/resources/i18n/fdmprinter.def.json.pot b/resources/i18n/fdmprinter.def.json.pot index d56e6f7012..e05954c32f 100644 --- a/resources/i18n/fdmprinter.def.json.pot +++ b/resources/i18n/fdmprinter.def.json.pot @@ -3106,6 +3106,18 @@ msgid "" "available when combing is enabled." msgstr "" +#: fdmprinter.def.json +msgctxt "travel_avoid_supports label" +msgid "Avoid Supports When Traveling" +msgstr "" + +#: fdmprinter.def.json +msgctxt "travel_avoid_supports description" +msgid "" +"The nozzle avoids already printed supports when traveling. This option is only " +"available when combing is enabled." +msgstr "" + #: fdmprinter.def.json msgctxt "travel_avoid_distance label" msgid "Travel Avoid Distance" diff --git a/resources/qml/MonitorButton.qml b/resources/qml/MonitorButton.qml index 9888b811e4..0bae22e164 100644 --- a/resources/qml/MonitorButton.qml +++ b/resources/qml/MonitorButton.qml @@ -15,6 +15,8 @@ Item id: base; UM.I18nCatalog { id: catalog; name:"cura"} + height: childrenRect.height + UM.Theme.getSize("sidebar_margin").height + property bool printerConnected: Cura.MachineManager.printerConnected property bool printerAcceptsCommands: printerConnected && Cura.MachineManager.printerOutputDevices[0].acceptsCommands property var activePrinter: printerConnected ? Cura.MachineManager.printerOutputDevices[0].activePrinter : null diff --git a/resources/qml/MonitorSidebar.qml b/resources/qml/MonitorSidebar.qml new file mode 100644 index 0000000000..b761b05380 --- /dev/null +++ b/resources/qml/MonitorSidebar.qml @@ -0,0 +1,211 @@ +// Copyright (c) 2017 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.7 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.3 + +import UM 1.2 as UM +import Cura 1.0 as Cura +import "Menus" +import "Menus/ConfigurationMenu" + +Rectangle +{ + id: base + + property int currentModeIndex + property bool hideSettings: PrintInformation.preSliced + property bool hideView: Cura.MachineManager.activeMachineName == "" + + // Is there an output device for this printer? + property bool isNetworkPrinter: Cura.MachineManager.activeMachineNetworkKey != "" + property bool printerConnected: Cura.MachineManager.printerConnected + property bool printerAcceptsCommands: printerConnected && Cura.MachineManager.printerOutputDevices[0].acceptsCommands + property var connectedPrinter: Cura.MachineManager.printerOutputDevices.length >= 1 ? Cura.MachineManager.printerOutputDevices[0] : null + + property variant printDuration: PrintInformation.currentPrintTime + property variant printMaterialLengths: PrintInformation.materialLengths + property variant printMaterialWeights: PrintInformation.materialWeights + property variant printMaterialCosts: PrintInformation.materialCosts + property variant printMaterialNames: PrintInformation.materialNames + + color: UM.Theme.getColor("sidebar") + UM.I18nCatalog { id: catalog; name:"cura"} + + Timer { + id: tooltipDelayTimer + interval: 500 + repeat: false + property var item + property string text + + onTriggered: + { + base.showTooltip(base, {x: 0, y: item.y}, text); + } + } + + function showTooltip(item, position, text) + { + tooltip.text = text; + position = item.mapToItem(base, position.x - UM.Theme.getSize("default_arrow").width, position.y); + tooltip.show(position); + } + + function hideTooltip() + { + tooltip.hide(); + } + + function strPadLeft(string, pad, length) { + return (new Array(length + 1).join(pad) + string).slice(-length); + } + + function getPrettyTime(time) + { + var hours = Math.floor(time / 3600) + time -= hours * 3600 + var minutes = Math.floor(time / 60); + time -= minutes * 60 + var seconds = Math.floor(time); + + var finalTime = strPadLeft(hours, "0", 2) + ':' + strPadLeft(minutes,'0',2)+ ':' + strPadLeft(seconds,'0',2); + return finalTime; + } + + MouseArea + { + anchors.fill: parent + acceptedButtons: Qt.AllButtons + + onWheel: + { + wheel.accepted = true; + } + } + + MachineSelection + { + id: machineSelection + width: base.width - configSelection.width - separator.width + height: UM.Theme.getSize("sidebar_header").height + anchors.top: base.top + anchors.left: parent.left + } + + Rectangle + { + id: separator + visible: configSelection.visible + width: visible ? Math.round(UM.Theme.getSize("sidebar_lining_thin").height / 2) : 0 + height: UM.Theme.getSize("sidebar_header").height + color: UM.Theme.getColor("sidebar_lining_thin") + anchors.left: machineSelection.right + } + + ConfigurationSelection + { + id: configSelection + visible: isNetworkPrinter && printerConnected + width: visible ? Math.round(base.width * 0.15) : 0 + height: UM.Theme.getSize("sidebar_header").height + anchors.top: base.top + anchors.right: parent.right + panelWidth: base.width + } + + Loader + { + id: controlItem + anchors.bottom: footerSeparator.top + anchors.top: machineSelection.bottom + anchors.left: base.left + anchors.right: base.right + sourceComponent: + { + if(connectedPrinter != null) + { + if(connectedPrinter.controlItem != null) + { + return connectedPrinter.controlItem + } + } + return null + } + } + + Loader + { + anchors.bottom: footerSeparator.top + anchors.top: machineSelection.bottom + anchors.left: base.left + anchors.right: base.right + source: + { + if(controlItem.sourceComponent == null) + { + return "PrintMonitor.qml" + } + else + { + return "" + } + } + } + + Rectangle + { + id: footerSeparator + width: parent.width + height: UM.Theme.getSize("sidebar_lining").height + color: UM.Theme.getColor("sidebar_lining") + anchors.bottom: monitorButton.top + anchors.bottomMargin: UM.Theme.getSize("sidebar_margin").height + } + + // MonitorButton is actually the bottom footer panel. + MonitorButton + { + id: monitorButton + implicitWidth: base.width + anchors.bottom: parent.bottom + } + + SidebarTooltip + { + id: tooltip + } + + UM.SettingPropertyProvider + { + id: machineExtruderCount + + containerStackId: Cura.MachineManager.activeMachineId + key: "machine_extruder_count" + watchedProperties: [ "value" ] + storeIndex: 0 + } + + UM.SettingPropertyProvider + { + id: machineHeatedBed + + containerStackId: Cura.MachineManager.activeMachineId + key: "machine_heated_bed" + watchedProperties: [ "value" ] + storeIndex: 0 + } + + // Make the ConfigurationSelector react when the global container changes, otherwise if Cura is not connected to the printer, + // switching printers make no reaction + Connections + { + target: Cura.MachineManager + onGlobalContainerChanged: + { + base.isNetworkPrinter = Cura.MachineManager.activeMachineNetworkKey != "" + base.printerConnected = Cura.MachineManager.printerOutputDevices.length != 0 + } + } +} diff --git a/resources/qml/Preferences/GeneralPage.qml b/resources/qml/Preferences/GeneralPage.qml index f4fa7c1557..7841c7d506 100644 --- a/resources/qml/Preferences/GeneralPage.qml +++ b/resources/qml/Preferences/GeneralPage.qml @@ -79,6 +79,8 @@ UM.PreferencesPage scaleToFitCheckbox.checked = boolCheck(UM.Preferences.getValue("mesh/scale_to_fit")) UM.Preferences.resetPreference("mesh/scale_tiny_meshes") scaleTinyCheckbox.checked = boolCheck(UM.Preferences.getValue("mesh/scale_tiny_meshes")) + UM.Preferences.resetPreference("cura/select_models_on_load") + selectModelsOnLoadCheckbox.checked = boolCheck(UM.Preferences.getValue("cura/select_models_on_load")) UM.Preferences.resetPreference("cura/jobname_prefix") prefixJobNameCheckbox.checked = boolCheck(UM.Preferences.getValue("cura/jobname_prefix")) UM.Preferences.resetPreference("view/show_overhang"); @@ -498,6 +500,21 @@ UM.PreferencesPage } } + UM.TooltipArea + { + width: childrenRect.width + height: childrenRect.height + text: catalog.i18nc("@info:tooltip","Should models be selected after they are loaded?") + + CheckBox + { + id: selectModelsOnLoadCheckbox + text: catalog.i18nc("@option:check","Select models when loaded") + checked: boolCheck(UM.Preferences.getValue("cura/select_models_on_load")) + onCheckedChanged: UM.Preferences.setValue("cura/select_models_on_load", checked) + } + } + UM.TooltipArea { width: childrenRect.width diff --git a/resources/qml/Sidebar.qml b/resources/qml/PrepareSidebar.qml similarity index 92% rename from resources/qml/Sidebar.qml rename to resources/qml/PrepareSidebar.qml index 590ab29880..703cbb8844 100644 --- a/resources/qml/Sidebar.qml +++ b/resources/qml/PrepareSidebar.qml @@ -24,8 +24,6 @@ Rectangle property bool printerAcceptsCommands: printerConnected && Cura.MachineManager.printerOutputDevices[0].acceptsCommands property var connectedPrinter: Cura.MachineManager.printerOutputDevices.length >= 1 ? Cura.MachineManager.printerOutputDevices[0] : null - property bool monitoringPrint: UM.Controller.activeStage.stageId == "MonitorStage" - property variant printDuration: PrintInformation.currentPrintTime property variant printMaterialLengths: PrintInformation.materialLengths property variant printMaterialWeights: PrintInformation.materialWeights @@ -120,7 +118,7 @@ Rectangle SidebarHeader { id: header width: parent.width - visible: !hideSettings && (machineExtruderCount.properties.value > 1 || Cura.MachineManager.hasMaterials || Cura.MachineManager.hasVariants) && !monitoringPrint + visible: !hideSettings && (machineExtruderCount.properties.value > 1 || Cura.MachineManager.hasMaterials || Cura.MachineManager.hasVariants) anchors.top: machineSelection.bottom onShowTooltip: base.showTooltip(item, location, text) @@ -158,7 +156,7 @@ Rectangle width: Math.round(parent.width * 0.45) font: UM.Theme.getFont("large") color: UM.Theme.getColor("text") - visible: !monitoringPrint && !hideView + visible: !hideView } // Settings mode selection toggle @@ -185,7 +183,7 @@ Rectangle } } - visible: !monitoringPrint && !hideSettings && !hideView + visible: !hideSettings && !hideView Component { @@ -282,7 +280,7 @@ Rectangle anchors.topMargin: UM.Theme.getSize("sidebar_margin").height anchors.left: base.left anchors.right: base.right - visible: !monitoringPrint && !hideSettings + visible: !hideSettings replaceEnter: Transition { PropertyAnimation { @@ -305,47 +303,11 @@ Rectangle Loader { - id: controlItem anchors.bottom: footerSeparator.top - anchors.top: monitoringPrint ? machineSelection.bottom : headerSeparator.bottom + anchors.top: headerSeparator.bottom anchors.left: base.left anchors.right: base.right - sourceComponent: - { - if(monitoringPrint && connectedPrinter != null) - { - if(connectedPrinter.controlItem != null) - { - return connectedPrinter.controlItem - } - } - return null - } - } - - Loader - { - anchors.bottom: footerSeparator.top - anchors.top: monitoringPrint ? machineSelection.bottom : headerSeparator.bottom - anchors.left: base.left - anchors.right: base.right - source: - { - if(controlItem.sourceComponent == null) - { - if(monitoringPrint) - { - return "PrintMonitor.qml" - } else - { - return "SidebarContents.qml" - } - } - else - { - return "" - } - } + source: "SidebarContents.qml" } Rectangle @@ -367,7 +329,6 @@ Rectangle anchors.bottomMargin: UM.Theme.getSize("sidebar_margin").height height: timeDetails.height + costSpec.height width: base.width - (saveButton.buttonRowWidth + UM.Theme.getSize("sidebar_margin").width) - visible: !monitoringPrint clip: true Label @@ -570,8 +531,7 @@ Rectangle } } - // SaveButton and MonitorButton are actually the bottom footer panels. - // "!monitoringPrint" currently means "show-settings-mode" + // SaveButton is actually the bottom footer panel. SaveButton { id: saveButton @@ -579,17 +539,6 @@ Rectangle anchors.top: footerSeparator.bottom anchors.topMargin: UM.Theme.getSize("sidebar_margin").height anchors.bottom: parent.bottom - visible: !monitoringPrint - } - - MonitorButton - { - id: monitorButton - implicitWidth: base.width - anchors.top: footerSeparator.bottom - anchors.topMargin: UM.Theme.getSize("sidebar_margin").height - anchors.bottom: parent.bottom - visible: monitoringPrint } SidebarTooltip diff --git a/resources/qml/SidebarHeader.qml b/resources/qml/SidebarHeader.qml index d8891972da..6fb6c841fc 100644 --- a/resources/qml/SidebarHeader.qml +++ b/resources/qml/SidebarHeader.qml @@ -17,7 +17,17 @@ Column property int currentExtruderIndex: Cura.ExtruderManager.activeExtruderIndex; property bool currentExtruderVisible: extrudersList.visible; property bool printerConnected: Cura.MachineManager.printerConnected - property bool hasManyPrinterTypes: printerConnected ? Cura.MachineManager.printerOutputDevices[0].connectedPrintersTypeCount.length > 1 : false + property bool hasManyPrinterTypes: + { + if (printerConnected) + { + if (Cura.MachineManager.printerOutputDevices[0].connectedPrintersTypeCount != null) + { + return Cura.MachineManager.printerOutputDevices[0].connectedPrintersTypeCount.length > 1; + } + } + return false; + } property bool buildplateCompatibilityError: !Cura.MachineManager.variantBuildplateCompatible && !Cura.MachineManager.variantBuildplateUsable property bool buildplateCompatibilityWarning: Cura.MachineManager.variantBuildplateUsable diff --git a/resources/setting_visibility/advanced.cfg b/resources/setting_visibility/advanced.cfg index e68ee787f5..4d4129f2cb 100644 --- a/resources/setting_visibility/advanced.cfg +++ b/resources/setting_visibility/advanced.cfg @@ -72,6 +72,7 @@ jerk_enabled [travel] retraction_combing travel_avoid_other_parts +travel_avoid_supports travel_avoid_distance retraction_hop_enabled retraction_hop_only_when_collides diff --git a/resources/setting_visibility/expert.cfg b/resources/setting_visibility/expert.cfg index db271cc985..6d6b84883c 100644 --- a/resources/setting_visibility/expert.cfg +++ b/resources/setting_visibility/expert.cfg @@ -187,6 +187,7 @@ jerk_skirt_brim retraction_combing travel_retract_before_outer_wall travel_avoid_other_parts +travel_avoid_supports travel_avoid_distance start_layers_at_same_position layer_start_x diff --git a/tests/TestArrange.py b/tests/TestArrange.py index 4f6bb64118..f383fc0cf3 100755 --- a/tests/TestArrange.py +++ b/tests/TestArrange.py @@ -4,9 +4,27 @@ from cura.Arranging.Arrange import Arrange from cura.Arranging.ShapeArray import ShapeArray -def gimmeShapeArray(): - vertices = numpy.array([[-3, 1], [3, 1], [0, -3]]) - shape_arr = ShapeArray.fromPolygon(vertices) +## Triangle of area 12 +def gimmeTriangle(): + return numpy.array([[-3, 1], [3, 1], [0, -3]], dtype=numpy.int32) + + +## Boring square +def gimmeSquare(): + return numpy.array([[-2, -2], [2, -2], [2, 2], [-2, 2]], dtype=numpy.int32) + + +## Triangle of area 12 +def gimmeShapeArray(scale = 1.0): + vertices = gimmeTriangle() + shape_arr = ShapeArray.fromPolygon(vertices, scale = scale) + return shape_arr + + +## Boring square +def gimmeShapeArraySquare(scale = 1.0): + vertices = gimmeSquare() + shape_arr = ShapeArray.fromPolygon(vertices, scale = scale) return shape_arr @@ -20,9 +38,48 @@ def test_smoke_ShapeArray(): shape_arr = gimmeShapeArray() +## Test ShapeArray +def test_ShapeArray(): + scale = 1 + ar = Arrange(16, 16, 8, 8, scale = scale) + ar.centerFirst() + + shape_arr = gimmeShapeArray(scale) + print(shape_arr.arr) + count = len(numpy.where(shape_arr.arr == 1)[0]) + print(count) + assert count >= 10 # should approach 12 + + +## Test ShapeArray with scaling +def test_ShapeArray_scaling(): + scale = 2 + ar = Arrange(16, 16, 8, 8, scale = scale) + ar.centerFirst() + + shape_arr = gimmeShapeArray(scale) + print(shape_arr.arr) + count = len(numpy.where(shape_arr.arr == 1)[0]) + print(count) + assert count >= 40 # should approach 2*2*12 = 48 + + +## Test ShapeArray with scaling +def test_ShapeArray_scaling2(): + scale = 0.5 + ar = Arrange(16, 16, 8, 8, scale = scale) + ar.centerFirst() + + shape_arr = gimmeShapeArray(scale) + print(shape_arr.arr) + count = len(numpy.where(shape_arr.arr == 1)[0]) + print(count) + assert count >= 1 # should approach 3, but it can be inaccurate due to pixel rounding + + ## Test centerFirst def test_centerFirst(): - ar = Arrange(300, 300, 150, 150) + ar = Arrange(300, 300, 150, 150, scale = 1) ar.centerFirst() assert ar._priority[150][150] < ar._priority[170][150] assert ar._priority[150][150] < ar._priority[150][170] @@ -32,19 +89,39 @@ def test_centerFirst(): assert ar._priority[150][150] < ar._priority[130][130] +## Test centerFirst +def test_centerFirst_rectangular(): + ar = Arrange(400, 300, 200, 150, scale = 1) + ar.centerFirst() + assert ar._priority[150][200] < ar._priority[150][220] + assert ar._priority[150][200] < ar._priority[170][200] + assert ar._priority[150][200] < ar._priority[170][220] + assert ar._priority[150][200] < ar._priority[180][150] + assert ar._priority[150][200] < ar._priority[130][200] + assert ar._priority[150][200] < ar._priority[130][180] + + +## Test centerFirst +def test_centerFirst_rectangular(): + ar = Arrange(10, 20, 5, 10, scale = 1) + ar.centerFirst() + print(ar._priority) + assert ar._priority[10][5] < ar._priority[10][7] + + ## Test backFirst def test_backFirst(): - ar = Arrange(300, 300, 150, 150) + ar = Arrange(300, 300, 150, 150, scale = 1) ar.backFirst() - assert ar._priority[150][150] < ar._priority[150][170] + assert ar._priority[150][150] < ar._priority[170][150] assert ar._priority[150][150] < ar._priority[170][170] - assert ar._priority[150][150] > ar._priority[150][130] + assert ar._priority[150][150] > ar._priority[130][150] assert ar._priority[150][150] > ar._priority[130][130] ## See if the result of bestSpot has the correct form def test_smoke_bestSpot(): - ar = Arrange(30, 30, 15, 15) + ar = Arrange(30, 30, 15, 15, scale = 1) ar.centerFirst() shape_arr = gimmeShapeArray() @@ -55,6 +132,113 @@ def test_smoke_bestSpot(): assert hasattr(best_spot, "priority") +## Real life test +def test_bestSpot(): + ar = Arrange(16, 16, 8, 8, scale = 1) + ar.centerFirst() + + shape_arr = gimmeShapeArray() + best_spot = ar.bestSpot(shape_arr) + assert best_spot.x == 0 + assert best_spot.y == 0 + ar.place(best_spot.x, best_spot.y, shape_arr) + + # Place object a second time + best_spot = ar.bestSpot(shape_arr) + assert best_spot.x is not None # we found a location + assert best_spot.x != 0 or best_spot.y != 0 # it can't be on the same location + ar.place(best_spot.x, best_spot.y, shape_arr) + + print(ar._occupied) # For debugging + + +## Real life test rectangular build plate +def test_bestSpot_rectangular_build_plate(): + ar = Arrange(16, 40, 8, 20, scale = 1) + ar.centerFirst() + + shape_arr = gimmeShapeArray() + best_spot = ar.bestSpot(shape_arr) + ar.place(best_spot.x, best_spot.y, shape_arr) + assert best_spot.x == 0 + assert best_spot.y == 0 + + # Place object a second time + best_spot2 = ar.bestSpot(shape_arr) + assert best_spot2.x is not None # we found a location + assert best_spot2.x != 0 or best_spot2.y != 0 # it can't be on the same location + ar.place(best_spot2.x, best_spot2.y, shape_arr) + + # Place object a 3rd time + best_spot3 = ar.bestSpot(shape_arr) + assert best_spot3.x is not None # we found a location + assert best_spot3.x != best_spot.x or best_spot3.y != best_spot.y # it can't be on the same location + assert best_spot3.x != best_spot2.x or best_spot3.y != best_spot2.y # it can't be on the same location + ar.place(best_spot3.x, best_spot3.y, shape_arr) + + best_spot_x = ar.bestSpot(shape_arr) + ar.place(best_spot_x.x, best_spot_x.y, shape_arr) + + best_spot_x = ar.bestSpot(shape_arr) + ar.place(best_spot_x.x, best_spot_x.y, shape_arr) + + best_spot_x = ar.bestSpot(shape_arr) + ar.place(best_spot_x.x, best_spot_x.y, shape_arr) + + print(ar._occupied) # For debugging + + +## Real life test +def test_bestSpot_scale(): + scale = 0.5 + ar = Arrange(16, 16, 8, 8, scale = scale) + ar.centerFirst() + + shape_arr = gimmeShapeArray(scale) + best_spot = ar.bestSpot(shape_arr) + assert best_spot.x == 0 + assert best_spot.y == 0 + ar.place(best_spot.x, best_spot.y, shape_arr) + + print(ar._occupied) + + # Place object a second time + best_spot = ar.bestSpot(shape_arr) + assert best_spot.x is not None # we found a location + assert best_spot.x != 0 or best_spot.y != 0 # it can't be on the same location + ar.place(best_spot.x, best_spot.y, shape_arr) + + print(ar._occupied) # For debugging + + +## Real life test +def test_bestSpot_scale_rectangular(): + scale = 0.5 + ar = Arrange(16, 40, 8, 20, scale = scale) + ar.centerFirst() + + shape_arr = gimmeShapeArray(scale) + + shape_arr_square = gimmeShapeArraySquare(scale) + best_spot = ar.bestSpot(shape_arr_square) + assert best_spot.x == 0 + assert best_spot.y == 0 + ar.place(best_spot.x, best_spot.y, shape_arr_square) + + print(ar._occupied) + + # Place object a second time + best_spot = ar.bestSpot(shape_arr) + assert best_spot.x is not None # we found a location + assert best_spot.x != 0 or best_spot.y != 0 # it can't be on the same location + ar.place(best_spot.x, best_spot.y, shape_arr) + + best_spot = ar.bestSpot(shape_arr_square) + ar.place(best_spot.x, best_spot.y, shape_arr_square) + + print(ar._occupied) # For debugging + + ## Try to place an object and see if something explodes def test_smoke_place(): ar = Arrange(30, 30, 15, 15) @@ -80,6 +264,20 @@ def test_checkShape(): assert points3 > points +## See of our center has less penalty points than out of the center +def test_checkShape_rectangular(): + ar = Arrange(20, 30, 10, 15) + ar.centerFirst() + print(ar._priority) + + shape_arr = gimmeShapeArray() + points = ar.checkShape(0, 0, shape_arr) + points2 = ar.checkShape(5, 0, shape_arr) + points3 = ar.checkShape(0, 5, shape_arr) + assert points2 > points + assert points3 > points + + ## Check that placing an object on occupied place returns None. def test_checkShape_place(): ar = Arrange(30, 30, 15, 15) @@ -95,7 +293,7 @@ def test_checkShape_place(): ## Test the whole sequence def test_smoke_place_objects(): - ar = Arrange(20, 20, 10, 10) + ar = Arrange(20, 20, 10, 10, scale = 1) ar.centerFirst() shape_arr = gimmeShapeArray() @@ -104,6 +302,13 @@ def test_smoke_place_objects(): ar.place(best_spot_x, best_spot_y, shape_arr) +# Test some internals +def test_compare_occupied_and_priority_tables(): + ar = Arrange(10, 15, 5, 7) + ar.centerFirst() + assert ar._priority.shape == ar._occupied.shape + + ## Polygon -> array def test_arrayFromPolygon(): vertices = numpy.array([[-3, 1], [3, 1], [0, -3]]) @@ -145,3 +350,30 @@ def test_check2(): assert numpy.any(check_array) assert not check_array[3][0] assert check_array[3][4] + + +## Just adding some stuff to ensure fromNode works as expected. Some parts should actually be in UM +def test_parts_of_fromNode(): + from UM.Math.Polygon import Polygon + p = Polygon(numpy.array([[-2, -2], [2, -2], [2, 2], [-2, 2]], dtype=numpy.int32)) + offset = 1 + print(p._points) + p_offset = p.getMinkowskiHull(Polygon.approximatedCircle(offset)) + print("--------------") + print(p_offset._points) + assert len(numpy.where(p_offset._points[:, 0] >= 2.9)) > 0 + assert len(numpy.where(p_offset._points[:, 0] <= -2.9)) > 0 + assert len(numpy.where(p_offset._points[:, 1] >= 2.9)) > 0 + assert len(numpy.where(p_offset._points[:, 1] <= -2.9)) > 0 + + +def test_parts_of_fromNode2(): + from UM.Math.Polygon import Polygon + p = Polygon(numpy.array([[-2, -2], [2, -2], [2, 2], [-2, 2]], dtype=numpy.int32) * 2) # 4x4 + offset = 13.3 + scale = 0.5 + p_offset = p.getMinkowskiHull(Polygon.approximatedCircle(offset)) + shape_arr1 = ShapeArray.fromPolygon(p._points, scale = scale) + shape_arr2 = ShapeArray.fromPolygon(p_offset._points, scale = scale) + assert shape_arr1.arr.shape[0] >= (4 * scale) - 1 # -1 is to account for rounding errors + assert shape_arr2.arr.shape[0] >= (2 * offset + 4) * scale - 1