Merge branch 'master' into feature_extruder_warning_icon

This commit is contained in:
fieldOfView 2021-11-16 14:56:43 +01:00
commit 19dbd1f168
4607 changed files with 36124 additions and 13817 deletions

View file

@ -1,7 +1,7 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime
from typing import Optional, Dict, TYPE_CHECKING, Callable
from typing import Any, Optional, Dict, TYPE_CHECKING, Callable
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS
@ -46,6 +46,9 @@ class Account(QObject):
loginStateChanged = pyqtSignal(bool)
"""Signal emitted when user logged in or out"""
additionalRightsChanged = pyqtSignal("QVariantMap")
"""Signal emitted when a users additional rights change"""
accessTokenChanged = pyqtSignal()
syncRequested = pyqtSignal()
"""Sync services may connect to this signal to receive sync triggers.
@ -59,7 +62,7 @@ class Account(QObject):
updatePackagesEnabledChanged = pyqtSignal(bool)
CLIENT_SCOPES = "account.user.read drive.backup.read drive.backup.write packages.download " \
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write " \
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write connect.material.write " \
"library.project.read library.project.write cura.printjob.read cura.printjob.write " \
"cura.mesh.read cura.mesh.write"
@ -70,6 +73,7 @@ class Account(QObject):
self._error_message = None # type: Optional[Message]
self._logged_in = False
self._additional_rights: Dict[str, Any] = {}
self._sync_state = SyncState.IDLE
self._manual_sync_enabled = False
self._update_packages_enabled = False
@ -301,3 +305,14 @@ class Account(QObject):
return # Nothing to do, user isn't logged in.
self._authorization_service.deleteAuthData()
def updateAdditionalRight(self, **kwargs) -> None:
"""Update the additional rights of the account.
The argument(s) are the rights that need to be set"""
self._additional_rights.update(kwargs)
self.additionalRightsChanged.emit(self._additional_rights)
@pyqtProperty("QVariantMap", notify = additionalRightsChanged)
def additionalRights(self) -> Dict[str, Any]:
"""A dictionary which can be queried for additional account rights."""
return self._additional_rights

View file

@ -13,7 +13,7 @@ DEFAULT_CURA_DEBUG_MODE = False
# Each release has a fixed SDK version coupled with it. It doesn't make sense to make it configurable because, for
# example Cura 3.2 with SDK version 6.1 will not work. So the SDK version is hard-coded here and left out of the
# CuraVersion.py.in template.
CuraSDKVersion = "7.6.0"
CuraSDKVersion = "7.8.0"
try:
from cura.CuraVersion import CuraAppName # type: ignore

View file

@ -110,18 +110,11 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
return found_solution_for_all, node_items
def arrange(nodes_to_arrange: List["SceneNode"], build_volume: "BuildVolume", fixed_nodes: Optional[List["SceneNode"]] = None, factor = 10000, add_new_nodes_in_scene: bool = False) -> bool:
"""
Find placement for a set of scene nodes, and move them by using a single grouped operation.
:param nodes_to_arrange: The list of nodes that need to be moved.
:param build_volume: The build volume that we want to place the nodes in. It gets size & disallowed areas from this.
:param fixed_nodes: List of nods that should not be moved, but should be used when deciding where the others nodes
are placed.
:param factor: The library that we use is int based. This factor defines how accuracte we want it to be.
:param add_new_nodes_in_scene: Whether to create new scene nodes before applying the transformations and rotations
:return: found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
"""
def createGroupOperationForArrange(nodes_to_arrange: List["SceneNode"],
build_volume: "BuildVolume",
fixed_nodes: Optional[List["SceneNode"]] = None,
factor = 10000,
add_new_nodes_in_scene: bool = False) -> Tuple[GroupedOperation, int]:
scene_root = Application.getInstance().getController().getScene().getRoot()
found_solution_for_all, node_items = findNodePlacement(nodes_to_arrange, build_volume, fixed_nodes, factor)
@ -143,6 +136,27 @@ def arrange(nodes_to_arrange: List["SceneNode"], build_volume: "BuildVolume", fi
grouped_operation.addOperation(
TranslateOperation(node, Vector(200, node.getWorldPosition().y, -not_fit_count * 20), set_position = True))
not_fit_count += 1
grouped_operation.push()
return found_solution_for_all
return grouped_operation, not_fit_count
def arrange(nodes_to_arrange: List["SceneNode"],
build_volume: "BuildVolume",
fixed_nodes: Optional[List["SceneNode"]] = None,
factor = 10000,
add_new_nodes_in_scene: bool = False) -> bool:
"""
Find placement for a set of scene nodes, and move them by using a single grouped operation.
:param nodes_to_arrange: The list of nodes that need to be moved.
:param build_volume: The build volume that we want to place the nodes in. It gets size & disallowed areas from this.
:param fixed_nodes: List of nods that should not be moved, but should be used when deciding where the others nodes
are placed.
:param factor: The library that we use is int based. This factor defines how accuracte we want it to be.
:param add_new_nodes_in_scene: Whether to create new scene nodes before applying the transformations and rotations
:return: found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
"""
grouped_operation, not_fit_count = createGroupOperationForArrange(nodes_to_arrange, build_volume, fixed_nodes, factor, add_new_nodes_in_scene)
grouped_operation.push()
return not_fit_count == 0

View file

@ -1,4 +1,4 @@
# Copyright (c) 2018 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import io
@ -168,7 +168,10 @@ class Backup:
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)
try:
shutil.move(backup_preferences_file, preferences_file)
except EnvironmentError as e:
Logger.error(f"Unable to back-up preferences file: {type(e)} - {str(e)}")
# Read the preferences from the newly restored configuration (or else the cached Preferences will override the restored ones)
self._application.readPreferencesFromConfiguration()
@ -203,6 +206,8 @@ class Backup:
archive.extract(archive_filename, target_path)
except (PermissionError, EnvironmentError):
Logger.logException("e", f"Unable to extract the file {archive_filename} from the backup due to permission or file system errors.")
except UnicodeEncodeError:
Logger.error(f"Unable to extract the file {archive_filename} because of an encoding error.")
CuraApplication.getInstance().processEvents()
return True

View file

@ -6,6 +6,7 @@ import math
from typing import List, Optional, TYPE_CHECKING, Any, Set, cast, Iterable, Dict
from UM.Logger import Logger
from UM.Mesh.MeshData import MeshData
from UM.Mesh.MeshBuilder import MeshBuilder
@ -289,7 +290,7 @@ class BuildVolume(SceneNode):
# Mark the node as outside build volume if the set extruder is disabled
extruder_position = node.callDecoration("getActiveExtruderPosition")
try:
if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled:
if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled and not node.callDecoration("isGroup"):
node.setOutsideBuildArea(True)
continue
except IndexError: # Happens when the extruder list is too short. We're not done building the printer in memory yet.
@ -1078,9 +1079,14 @@ class BuildVolume(SceneNode):
# setting does *not* have a limit_to_extruder setting (which means that we can't ask the global extruder what
# the value is.
adhesion_extruder = self._global_container_stack.getProperty("adhesion_extruder_nr", "value")
skirt_brim_line_width = self._global_container_stack.extruderList[int(adhesion_extruder)].getProperty("skirt_brim_line_width", "value")
try:
adhesion_stack = self._global_container_stack.extruderList[int(adhesion_extruder)]
except IndexError:
Logger.warning(f"Couldn't find extruder with index '{adhesion_extruder}', defaulting to 0 instead.")
adhesion_stack = self._global_container_stack.extruderList[0]
skirt_brim_line_width = adhesion_stack.getProperty("skirt_brim_line_width", "value")
initial_layer_line_width_factor = self._global_container_stack.getProperty("initial_layer_line_width_factor", "value")
initial_layer_line_width_factor = adhesion_stack.getProperty("initial_layer_line_width_factor", "value")
# Use brim width if brim is enabled OR the prime tower has a brim.
if adhesion_type == "brim":
brim_line_count = self._global_container_stack.getProperty("brim_line_count", "value")

View file

@ -129,7 +129,7 @@ class CuraApplication(QtApplication):
# SettingVersion represents the set of settings available in the machine/extruder definitions.
# You need to make sure that this version number needs to be increased if there is any non-backwards-compatible
# changes of the settings.
SettingVersion = 17
SettingVersion = 19
Created = False
@ -162,6 +162,7 @@ class CuraApplication(QtApplication):
self.default_theme = "cura-light"
self.change_log_url = "https://ultimaker.com/ultimaker-cura-latest-features?utm_source=cura&utm_medium=software&utm_campaign=cura-update-features"
self.beta_change_log_url = "https://ultimaker.com/ultimaker-cura-beta-features?utm_source=cura&utm_medium=software&utm_campaign=cura-update-features"
self._boot_loading_time = time.time()
@ -320,7 +321,7 @@ class CuraApplication(QtApplication):
super().initialize()
self._preferences.addPreference("cura/single_instance", False)
self._use_single_instance = self._preferences.getValue("cura/single_instance")
self._use_single_instance = self._preferences.getValue("cura/single_instance") or self._cli_args.single_instance
self.__sendCommandToSingleInstance()
self._initializeSettingDefinitions()
@ -471,7 +472,8 @@ class CuraApplication(QtApplication):
("definition_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.DefinitionChangesContainer, "application/x-uranium-instancecontainer"),
("variant", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.VariantInstanceContainer, "application/x-uranium-instancecontainer"),
("setting_visibility", SettingVisibilityPresetsModel.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.SettingVisibilityPreset, "application/x-uranium-preferences"),
("machine", 2): (Resources.DefinitionContainers, "application/x-uranium-definitioncontainer")
("machine", 2): (Resources.DefinitionContainers, "application/x-uranium-definitioncontainer"),
("extruder", 2): (Resources.DefinitionContainers, "application/x-uranium-definitioncontainer")
}
)
@ -715,6 +717,7 @@ class CuraApplication(QtApplication):
for extruder in global_stack.extruderList:
extruder.userChanges.clear()
global_stack.userChanges.clear()
self.getMachineManager().correctExtruderSettings()
# if the user decided to keep settings then the user settings should be re-calculated and validated for errors
# before slicing. To ensure that slicer uses right settings values
@ -749,7 +752,9 @@ class CuraApplication(QtApplication):
@pyqtSlot(str, result = QUrl)
def getDefaultPath(self, key):
default_path = self.getPreferences().getValue("local_file/%s" % key)
return QUrl.fromLocalFile(default_path)
if os.path.exists(default_path):
return QUrl.fromLocalFile(default_path)
return QUrl()
@pyqtSlot(str, str)
def setDefaultPath(self, key, default_path):
@ -1311,9 +1316,9 @@ class CuraApplication(QtApplication):
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.
continue # Node that doesn't have a mesh and is not a group.
if node.getParent() and node.getParent().callDecoration("isGroup") or node.getParent().callDecoration("isSliceable"):
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
if not node.isSelectable():
continue # i.e. node with layer data
if not node.callDecoration("isSliceable") and not node.callDecoration("isGroup"):
@ -1331,9 +1336,9 @@ class CuraApplication(QtApplication):
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.
continue # Node that doesn't 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)
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
if not node.isSelectable():
continue # i.e. node with layer data
nodes.append(node)
@ -1360,9 +1365,9 @@ class CuraApplication(QtApplication):
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.
continue # Node that doesn't 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)
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
if not node.callDecoration("isSliceable") and not node.callDecoration("isGroup"):
continue # i.e. node with layer data
nodes.append(node)
@ -1389,7 +1394,7 @@ class CuraApplication(QtApplication):
continue
if not node.getMeshData() and not node.callDecoration("isGroup"):
continue # Node that doesnt have a mesh and is not a group.
continue # Node that doesn't have a mesh and is not a group.
parent_node = node.getParent()
if parent_node and parent_node.callDecoration("isGroup"):
@ -1417,11 +1422,11 @@ class CuraApplication(QtApplication):
continue
if not node.getMeshData() and not node.callDecoration("isGroup"):
continue # Node that doesnt have a mesh and is not a group.
continue # Node that doesn't have a mesh and is not a group.
parent_node = node.getParent()
if parent_node and parent_node.callDecoration("isGroup"):
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
if not node.isSelectable():
continue # i.e. node with layer data
@ -2038,11 +2043,11 @@ class CuraApplication(QtApplication):
if not node.isEnabled():
continue
if (not node.getMeshData() and not node.callDecoration("getLayerData")) and not node.callDecoration("isGroup"):
continue # Node that doesnt have a mesh and is not a group.
continue # Node that doesn't have a mesh and is not a group.
if only_selectable and not node.isSelectable():
continue # Only remove nodes that are selectable.
if not node.callDecoration("isSliceable") and not node.callDecoration("getLayerData") and not node.callDecoration("isGroup"):
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
nodes.append(node)
if nodes:
from UM.Operations.GroupedOperation import GroupedOperation

View file

@ -12,7 +12,7 @@ from cura.CuraApplication import CuraApplication
# Since Cura has a few pre-defined "space claims" for the locations of certain components, we've provided some structure
# to indicate this.
# MainComponent works in the same way the MainComponent of a stage.
# the stageMenuComponent returns an item that should be used somehwere in the stage menu. It's up to the active stage
# the stageMenuComponent returns an item that should be used somewhere in the stage menu. It's up to the active stage
# to actually do something with this.
class CuraView(View):
def __init__(self, parent = None, use_empty_menu_placeholder: bool = False) -> None:

View file

@ -15,6 +15,7 @@ class Layer:
self._height = 0.0
self._thickness = 0.0
self._polygons = [] # type: List[LayerPolygon]
self._vertex_count = 0
self._element_count = 0
@property
@ -29,6 +30,10 @@ class Layer:
def polygons(self) -> List[LayerPolygon]:
return self._polygons
@property
def vertexCount(self):
return self._vertex_count
@property
def elementCount(self):
return self._element_count
@ -43,24 +48,40 @@ class Layer:
result = 0
for polygon in self._polygons:
result += polygon.lineMeshVertexCount()
return result
def lineMeshElementCount(self) -> int:
result = 0
for polygon in self._polygons:
result += polygon.lineMeshElementCount()
return result
def lineMeshCumulativeTypeChangeCount(self, path: int) -> int:
""" The number of line-type changes in this layer up until #path.
See also LayerPolygon::cumulativeTypeChangeCounts.
:param path: The path-index up until which the cumulative changes are counted.
:return: The cumulative number of line-type changes up until this path.
"""
result = 0
for polygon in self._polygons:
num_counts = len(polygon.cumulativeTypeChangeCounts)
if path < num_counts:
return result + polygon.cumulativeTypeChangeCounts[path]
path -= num_counts
result += polygon.cumulativeTypeChangeCounts[num_counts - 1]
return result
def build(self, vertex_offset, index_offset, vertices, colors, line_dimensions, feedrates, extruders, line_types, indices):
result_vertex_offset = vertex_offset
result_index_offset = index_offset
self._vertex_count = 0
self._element_count = 0
for polygon in self._polygons:
polygon.build(result_vertex_offset, result_index_offset, vertices, colors, line_dimensions, feedrates, extruders, line_types, indices)
result_vertex_offset += polygon.lineMeshVertexCount()
result_index_offset += polygon.lineMeshElementCount()
self._vertex_count += polygon.vertexCount
self._element_count += polygon.elementCount
return result_vertex_offset, result_index_offset

View file

@ -63,6 +63,7 @@ class LayerDataBuilder(MeshBuilder):
feedrates = numpy.empty((vertex_count), numpy.float32)
extruders = numpy.empty((vertex_count), numpy.float32)
line_types = numpy.empty((vertex_count), numpy.float32)
vertex_indices = numpy.arange(0, vertex_count, 1, dtype = numpy.float32)
vertex_offset = 0
index_offset = 0
@ -109,6 +110,12 @@ class LayerDataBuilder(MeshBuilder):
"value": feedrates,
"opengl_name": "a_feedrate",
"opengl_type": "float"
},
# Can't use glDrawElements to index (due to oversight in (Py)Qt), can't use gl_PrimitiveID (due to legacy support):
"vertex_indices": {
"value": vertex_indices,
"opengl_name": "a_vertex_index",
"opengl_type": "float"
}
}

View file

@ -55,11 +55,19 @@ class LayerPolygon:
self._jump_mask = self.__jump_map[self._types]
self._jump_count = numpy.sum(self._jump_mask)
self._cumulative_type_change_counts = numpy.zeros(len(self._types)) # See the comment on the 'cumulativeTypeChangeCounts' property below.
last_type = self.types[0]
current_type_count = 0
for i in range(0, len(self._cumulative_type_change_counts)):
if last_type != self.types[i]:
current_type_count += 1
last_type = self.types[i]
self._cumulative_type_change_counts[i] = current_type_count
self._mesh_line_count = len(self._types) - self._jump_count
self._vertex_count = self._mesh_line_count + numpy.sum(self._types[1:] == self._types[:-1])
# Buffering the colors shouldn't be necessary as it is not
# re-used and can save alot of memory usage.
# re-used and can save a lot of memory usage.
self._color_map = LayerPolygon.getColorMap()
self._colors = self._color_map[self._types] # type: numpy.ndarray
@ -146,7 +154,7 @@ class LayerPolygon:
# When the line type changes the index needs to be increased by 2.
indices[self._index_begin:self._index_end, :] += numpy.cumsum(needed_points_list[line_mesh_mask.ravel(), 0], dtype = numpy.int32).reshape((-1, 1))
# Each line segment goes from it's starting point p to p+1, offset by the vertex index.
# The -1 is to compensate for the neccecarily True value of needed_points_list[0,0] which causes an unwanted +1 in cumsum above.
# The -1 is to compensate for the necessarily True value of needed_points_list[0,0] which causes an unwanted +1 in cumsum above.
indices[self._index_begin:self._index_end, :] += numpy.array([self._vertex_begin - 1, self._vertex_begin])
self._build_cache_line_mesh_mask = None
@ -179,6 +187,10 @@ class LayerPolygon:
def data(self):
return self._data
@property
def vertexCount(self):
return self._vertex_end - self._vertex_begin
@property
def elementCount(self):
return (self._index_end - self._index_begin) * 2 # The range of vertices multiplied by 2 since each vertex is used twice
@ -207,6 +219,17 @@ class LayerPolygon:
def jumpCount(self):
return self._jump_count
@property
def cumulativeTypeChangeCounts(self):
""" This polygon class stores with a vertex the type of the line to the next vertex. However, in other contexts,
other ways of representing this might be more suited to the task (for example, when a vertex can possibly only
have _one_ type, it's unavoidable to duplicate vertices when the type is changed). In such situations it's might
be useful to know how many times the type has changed, in order to keep the various vertex-indices aligned.
:return: The total times the line-type changes from one type to another within this LayerPolygon.
"""
return self._cumulative_type_change_counts
def getNormals(self) -> numpy.ndarray:
"""Calculate normals for the entire polygon using numpy.

View file

@ -97,8 +97,7 @@ class MachineErrorChecker(QObject):
def startErrorCheckPropertyChanged(self, key: str, property_name: str) -> None:
"""Start the error check for property changed
this is seperate from the startErrorCheck because it ignores a number property types
this is separate from the startErrorCheck because it ignores a number property types
:param key:
:param property_name:

View file

@ -59,6 +59,8 @@ class ExtrudersModel(ListModel):
defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"]
"""List of colours to display if there is no material or the material has no known colour. """
MaterialNameRole = Qt.UserRole + 13
def __init__(self, parent = None):
"""Initialises the extruders model, defining the roles and listening for changes in the data.
@ -79,6 +81,7 @@ class ExtrudersModel(ListModel):
self.addRoleName(self.MaterialBrandRole, "material_brand")
self.addRoleName(self.ColorNameRole, "color_name")
self.addRoleName(self.MaterialTypeRole, "material_type")
self.addRoleName(self.MaterialNameRole, "material_name")
self._update_extruder_timer = QTimer()
self._update_extruder_timer.setInterval(100)
self._update_extruder_timer.setSingleShot(True)
@ -199,8 +202,8 @@ class ExtrudersModel(ListModel):
"material_brand": material_brand,
"color_name": color_name,
"material_type": extruder.material.getMetaDataEntry("material") if extruder.material else "",
"material_name": extruder.material.getMetaDataEntry("name") if extruder.material else "",
}
items.append(item)
extruders_changed = True
@ -224,6 +227,7 @@ class ExtrudersModel(ListModel):
"material_brand": "",
"color_name": "",
"material_type": "",
"material_label": ""
}
items.append(item)
if self._items != items:

View file

@ -1,7 +1,8 @@
# Copyright (c) 2018 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtCore import Qt, QTimer, pyqtProperty, pyqtSignal
from typing import Optional
from UM.Qt.ListModel import ListModel
from UM.i18n import i18nCatalog
@ -20,6 +21,7 @@ class GlobalStacksModel(ListModel):
MetaDataRole = Qt.UserRole + 5
DiscoverySourceRole = Qt.UserRole + 6 # For separating local and remote printers in the machine management page
RemovalWarningRole = Qt.UserRole + 7
IsOnlineRole = Qt.UserRole + 8
def __init__(self, parent = None) -> None:
super().__init__(parent)
@ -31,18 +33,49 @@ class GlobalStacksModel(ListModel):
self.addRoleName(self.HasRemoteConnectionRole, "hasRemoteConnection")
self.addRoleName(self.MetaDataRole, "metadata")
self.addRoleName(self.DiscoverySourceRole, "discoverySource")
self.addRoleName(self.IsOnlineRole, "isOnline")
self._change_timer = QTimer()
self._change_timer.setInterval(200)
self._change_timer.setSingleShot(True)
self._change_timer.timeout.connect(self._update)
self._filter_connection_type = None # type: Optional[ConnectionType]
self._filter_online_only = False
# Listen to changes
CuraContainerRegistry.getInstance().containerAdded.connect(self._onContainerChanged)
CuraContainerRegistry.getInstance().containerMetaDataChanged.connect(self._onContainerChanged)
CuraContainerRegistry.getInstance().containerRemoved.connect(self._onContainerChanged)
self._updateDelayed()
filterConnectionTypeChanged = pyqtSignal()
def setFilterConnectionType(self, new_filter: Optional[ConnectionType]) -> None:
self._filter_connection_type = new_filter
@pyqtProperty(int, fset = setFilterConnectionType, notify = filterConnectionTypeChanged)
def filterConnectionType(self) -> int:
"""
The connection type to filter the list of printers by.
Only printers that match this connection type will be listed in the
model.
"""
if self._filter_connection_type is None:
return -1
return self._filter_connection_type.value
filterOnlineOnlyChanged = pyqtSignal()
def setFilterOnlineOnly(self, new_filter: bool) -> None:
self._filter_online_only = new_filter
@pyqtProperty(bool, fset = setFilterOnlineOnly, notify = filterOnlineOnlyChanged)
def filterOnlineOnly(self) -> bool:
"""
Whether to filter the global stacks to show only printers that are online.
"""
return self._filter_online_only
def _onContainerChanged(self, container) -> None:
"""Handler for container added/removed events from registry"""
@ -58,6 +91,10 @@ class GlobalStacksModel(ListModel):
container_stacks = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine")
for container_stack in container_stacks:
if self._filter_connection_type is not None: # We want to filter on connection types.
if not any((connection_type == self._filter_connection_type for connection_type in container_stack.configuredConnectionTypes)):
continue # No connection type on this printer matches the filter.
has_remote_connection = False
for connection_type in container_stack.configuredConnectionTypes:
@ -67,6 +104,10 @@ class GlobalStacksModel(ListModel):
if parseBool(container_stack.getMetaDataEntry("hidden", False)):
continue
is_online = container_stack.getMetaDataEntry("is_online", False)
if self._filter_online_only and not is_online:
continue
device_name = container_stack.getMetaDataEntry("group_name", container_stack.getName())
section_name = "Connected printers" if has_remote_connection else "Preset printers"
section_name = self._catalog.i18nc("@info:title", section_name)
@ -82,6 +123,7 @@ class GlobalStacksModel(ListModel):
"hasRemoteConnection": has_remote_connection,
"metadata": container_stack.getMetaData().copy(),
"discoverySource": section_name,
"removalWarning": removal_warning})
"removalWarning": removal_warning,
"isOnline": is_online})
items.sort(key=lambda i: (not i["hasRemoteConnection"], i["name"]))
self.setItems(items)

View file

@ -107,7 +107,7 @@ class IntentCategoryModel(ListModel):
qualities = IntentModel()
qualities.setIntentCategory(category)
result.append({
"name": IntentCategoryModel.translation(category, "name", catalog.i18nc("@label", "Unknown")),
"name": IntentCategoryModel.translation(category, "name", category),
"description": IntentCategoryModel.translation(category, "description", None),
"intent_category": category,
"weight": list(IntentCategoryModel._get_translations().keys()).index(category),

View file

@ -2,24 +2,28 @@
# Cura is released under the terms of the LGPLv3 or higher.
import copy # To duplicate materials.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QUrl
from PyQt5.QtGui import QDesktopServices
from typing import Any, Dict, Optional, TYPE_CHECKING
import uuid # To generate new GUIDs for new materials.
import zipfile # To export all materials in a .zip archive.
from UM.Message import Message
from UM.i18n import i18nCatalog
from UM.Logger import Logger
from UM.Resources import Resources # To find QML files.
from UM.Signal import postponeSignals, CompressTechnique
import cura.CuraApplication # Imported like this to prevent circular imports.
import cura.CuraApplication # Imported like this to prevent cirmanagecular imports.
from cura.Machines.ContainerTree import ContainerTree
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To find the sets of materials belonging to each other, and currently loaded extruder stacks.
from cura.UltimakerCloud.CloudMaterialSync import CloudMaterialSync
if TYPE_CHECKING:
from cura.Machines.MaterialNode import MaterialNode
catalog = i18nCatalog("cura")
class MaterialManagementModel(QObject):
favoritesChanged = pyqtSignal(str)
"""Triggered when a favorite is added or removed.
@ -27,6 +31,66 @@ class MaterialManagementModel(QObject):
:param The base file of the material is provided as parameter when this emits
"""
def __init__(self, parent: Optional[QObject] = None) -> None:
super().__init__(parent = parent)
self._material_sync = CloudMaterialSync(parent=self)
self._checkIfNewMaterialsWereInstalled()
def _checkIfNewMaterialsWereInstalled(self) -> None:
"""
Checks whether new material packages were installed in the latest startup. If there were, then it shows
a message prompting the user to sync the materials with their printers.
"""
application = cura.CuraApplication.CuraApplication.getInstance()
for package_id, package_data in application.getPackageManager().getPackagesInstalledOnStartup().items():
if package_data["package_info"]["package_type"] == "material":
# At least one new material was installed
# TODO: This should be enabled again once CURA-8609 is merged
#self._showSyncNewMaterialsMessage()
break
def _showSyncNewMaterialsMessage(self) -> None:
sync_materials_message = Message(
text = catalog.i18nc("@action:button",
"Please sync the material profiles with your printers before starting to print."),
title = catalog.i18nc("@action:button", "New materials installed"),
message_type = Message.MessageType.WARNING,
lifetime = 0
)
sync_materials_message.addAction(
"sync",
name = catalog.i18nc("@action:button", "Sync materials with printers"),
icon = "",
description = "Sync your newly installed materials with your printers.",
button_align = Message.ActionButtonAlignment.ALIGN_RIGHT
)
sync_materials_message.addAction(
"learn_more",
name = catalog.i18nc("@action:button", "Learn more"),
icon = "",
description = "Learn more about syncing your newly installed materials with your printers.",
button_align = Message.ActionButtonAlignment.ALIGN_LEFT,
button_style = Message.ActionButtonStyle.LINK
)
sync_materials_message.actionTriggered.connect(self._onSyncMaterialsMessageActionTriggered)
# Show the message only if there are printers that support material export
container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
global_stacks = container_registry.findContainerStacks(type = "machine")
if any([stack.supportsMaterialExport for stack in global_stacks]):
sync_materials_message.show()
def _onSyncMaterialsMessageActionTriggered(self, sync_message: Message, sync_message_action: str):
if sync_message_action == "sync":
QDesktopServices.openUrl(QUrl("https://example.com/openSyncAllWindow"))
# self.openSyncAllWindow()
sync_message.hide()
elif sync_message_action == "learn_more":
QDesktopServices.openUrl(QUrl("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-printer-message"))
@pyqtSlot("QVariant", result = bool)
def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool:
"""Can a certain material be deleted, or is it still in use in one of the container stacks anywhere?
@ -261,39 +325,10 @@ class MaterialManagementModel(QObject):
except ValueError: # Material was not in the favorites list.
Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file))
@pyqtSlot(result = QUrl)
def getPreferredExportAllPath(self) -> QUrl:
@pyqtSlot()
def openSyncAllWindow(self) -> None:
"""
Get the preferred path to export materials to.
Opens the window to sync all materials.
"""
self._material_sync.openSyncAllWindow()
If there is a removable drive, that should be the preferred path. Otherwise it should be the most recent local
file path.
:return: The preferred path to export all materials to.
"""
cura_application = cura.CuraApplication.CuraApplication.getInstance()
device_manager = cura_application.getOutputDeviceManager()
devices = device_manager.getOutputDevices()
for device in devices:
if device.__class__.__name__ == "RemovableDriveOutputDevice":
return QUrl.fromLocalFile(device.getId())
else: # No removable drives? Use local path.
return cura_application.getDefaultPath("dialog_material_path")
@pyqtSlot(QUrl)
def exportAll(self, file_path: QUrl) -> None:
"""
Export all materials to a certain file path.
:param file_path: The path to export the materials to.
"""
registry = CuraContainerRegistry.getInstance()
archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED)
for metadata in registry.findInstanceContainersMetadata(type = "material"):
if metadata["base_file"] != metadata["id"]: # Only process base files.
continue
if metadata["id"] == "empty_material": # Don't export the empty material.
continue
material = registry.findContainers(id = metadata["id"])[0]
suffix = registry.getMimeTypeForContainer(type(material)).preferredSuffix
filename = metadata["id"] + "." + suffix
archive.writestr(filename, material.serialize())

View file

@ -41,10 +41,6 @@ class QualityProfilesDropDownMenuModel(ListModel):
machine_manager.activeQualityGroupChanged.connect(self._onChange)
machine_manager.activeMaterialChanged.connect(self._onChange)
machine_manager.activeVariantChanged.connect(self._onChange)
machine_manager.extruderChanged.connect(self._onChange)
extruder_manager = application.getExtruderManager()
extruder_manager.extrudersChanged.connect(self._onChange)
self._layer_height_unit = "" # This is cached

View file

@ -33,7 +33,7 @@ class SettingVisibilityPresetsModel(QObject):
if basic_item is not None:
basic_visibile_settings = ";".join(basic_item.settings)
else:
Logger.log("w", "Unable to find the basic visiblity preset.")
Logger.log("w", "Unable to find the basic visibility preset.")
basic_visibile_settings = ""
self._preferences = preferences

View file

@ -6,11 +6,15 @@ from typing import List
from UM.Application import Application
from UM.Job import Job
from UM.Math.Vector import Vector
from UM.Message import Message
from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
from UM.Operations.GroupedOperation import GroupedOperation
from UM.Operations.TranslateOperation import TranslateOperation
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Scene.SceneNode import SceneNode
from UM.i18n import i18nCatalog
from cura.Arranging.Nest2DArrange import arrange
from cura.Arranging.Nest2DArrange import arrange, createGroupOperationForArrange
i18n_catalog = i18nCatalog("cura")
@ -43,11 +47,11 @@ class MultiplyObjectsJob(Job):
# Only count sliceable objects
if node_.callDecoration("isSliceable"):
fixed_nodes.append(node_)
nodes_to_add_without_arrange = []
for node in self._objects:
# If object is part of a group, multiply group
current_node = node
while current_node.getParent() and (current_node.getParent().callDecoration("isGroup") or current_node.getParent().callDecoration("isSliceable")):
while current_node.getParent() and current_node.getParent().callDecoration("isGroup"):
current_node = current_node.getParent()
if current_node in processed_nodes:
@ -56,19 +60,38 @@ class MultiplyObjectsJob(Job):
for _ in range(self._count):
new_node = copy.deepcopy(node)
# Same build plate
build_plate_number = current_node.callDecoration("getBuildPlateNumber")
new_node.callDecoration("setBuildPlateNumber", build_plate_number)
for child in new_node.getChildren():
child.callDecoration("setBuildPlateNumber", build_plate_number)
nodes.append(new_node)
if not current_node.getParent().callDecoration("isSliceable"):
nodes.append(new_node)
else:
# The node we're trying to place has another node that is sliceable as a parent.
# As such, we shouldn't arrange it (but it should be added to the scene!)
nodes_to_add_without_arrange.append(new_node)
new_node.setParent(current_node.getParent())
found_solution_for_all = True
group_operation = GroupedOperation()
if nodes:
found_solution_for_all = arrange(nodes, Application.getInstance().getBuildVolume(), fixed_nodes,
factor = 10000, add_new_nodes_in_scene = True)
group_operation, not_fit_count = createGroupOperationForArrange(nodes,
Application.getInstance().getBuildVolume(),
fixed_nodes,
factor = 10000,
add_new_nodes_in_scene = True)
found_solution_for_all = not_fit_count == 0
if nodes_to_add_without_arrange:
for nested_node in nodes_to_add_without_arrange:
group_operation.addOperation(AddSceneNodeOperation(nested_node, nested_node.getParent()))
# Move the node a tiny bit so it doesn't overlap with the existing one.
# This doesn't fix it if someone creates more than one duplicate, but it at least shows that something
# happened (and after moving it, it's clear that there are more underneath)
group_operation.addOperation(TranslateOperation(nested_node, Vector(2.5, 2.5, 2.5)))
group_operation.push()
status_message.hide()
if not found_solution_for_all:

View file

@ -3,7 +3,7 @@
from datetime import datetime
import json
import random
import secrets
from hashlib import sha512
from base64 import b64encode
from typing import Optional
@ -48,8 +48,8 @@ class AuthorizationHelpers:
}
try:
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore
except requests.exceptions.ConnectionError:
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server")
except requests.exceptions.ConnectionError as connection_error:
return AuthenticationResponse(success = False, err_message = f"Unable to connect to remote server: {connection_error}")
def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse":
"""Request the access token from the authorization server using a refresh token.
@ -139,11 +139,11 @@ class AuthorizationHelpers:
def generateVerificationCode(code_length: int = 32) -> str:
"""Generate a verification code of arbitrary length.
:param code_length:: How long should the code be? This should never be lower than 16, but it's probably
:param code_length:: How long should the code be in bytes? This should never be lower than 16, but it's probably
better to leave it at 32
"""
return "".join(random.choice("0123456789ABCDEF") for i in range(code_length))
return secrets.token_hex(code_length)
@staticmethod
def generateVerificationCodeChallenge(verification_code: str) -> str:

View file

@ -99,7 +99,14 @@ class AuthorizationService:
# If no auth data exists, we should always log in again.
Logger.log("d", "There was no auth data or access token")
return None
user_data = self._auth_helpers.parseJWT(self._auth_data.access_token)
try:
user_data = self._auth_helpers.parseJWT(self._auth_data.access_token)
except AttributeError:
# THis might seem a bit double, but we get crash reports about this (CURA-2N2 in sentry)
Logger.log("d", "There was no auth data or access token")
return None
if user_data:
# If the profile was found, we return it immediately.
return user_data
@ -120,7 +127,7 @@ class AuthorizationService:
return self._auth_helpers.parseJWT(self._auth_data.access_token)
def getAccessToken(self) -> Optional[str]:
"""Get the access token as provided by the repsonse data."""
"""Get the access token as provided by the response data."""
if self._auth_data is None:
Logger.log("d", "No auth data to retrieve the access_token from")

View file

@ -14,13 +14,18 @@ if TYPE_CHECKING:
# Need to do some extra workarounds on windows:
import sys
from UM.Platform import Platform
if Platform.isWindows() and hasattr(sys, "frozen"):
import win32timezone
if Platform.isWindows():
if hasattr(sys, "frozen"):
import win32timezone
from keyring.backends.Windows import WinVaultKeyring
keyring.set_keyring(WinVaultKeyring())
if Platform.isOSX() and hasattr(sys, "frozen"):
if Platform.isOSX():
from keyring.backends.macOS import Keyring
keyring.set_keyring(Keyring())
if Platform.isLinux():
# We do not support the keyring on Linux, so make sure no Keyring backend is loaded, even if there is a system one.
from keyring.backends.fail import Keyring as NoKeyringBackend
keyring.set_keyring(NoKeyringBackend())
# Even if errors happen, we don't want this stored locally:
DONT_EVER_STORE_LOCALLY: List[str] = ["refresh_token"]

View file

@ -79,7 +79,7 @@ class PickingPass(RenderPass):
return -1
distance = output.pixel(px, py) # distance in micron, from in r, g & b channels
distance = (distance & 0x00ffffff) / 1000. # drop the alpha channel and covert to mm
distance = (distance & 0x00ffffff) / 1000. # drop the alpha channel and convert to mm
return distance
def getPickedPosition(self, x: int, y: int) -> Vector:

View file

@ -49,7 +49,7 @@ class FirmwareUpdater(QObject):
raise NotImplementedError("_updateFirmware needs to be implemented")
def _cleanupAfterUpdate(self) -> None:
"""Cleanup after a succesful update"""
"""Cleanup after a successful update"""
# Clean up for next attempt.
self._update_firmware_thread = Thread(target=self._updateFirmware, daemon=True, name = "FirmwareUpdateThread")

View file

@ -1,4 +1,4 @@
# Copyright (c) 2018 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.FileHandler.FileHandler import FileHandler #For typing.
@ -114,6 +114,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
return b"".join(file_data_bytes_list)
def _update(self) -> None:
"""
Update the connection state of this device.
This is called on regular intervals.
"""
if self._last_response_time:
time_since_last_response = time() - self._last_response_time
else:
@ -127,11 +132,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
if time_since_last_response > self._timeout_time >= time_since_last_request:
# Go (or stay) into timeout.
if self._connection_state_before_timeout is None:
self._connection_state_before_timeout = self._connection_state
self._connection_state_before_timeout = self.connectionState
self.setConnectionState(ConnectionState.Closed)
elif self._connection_state == ConnectionState.Closed:
elif self.connectionState == ConnectionState.Closed:
# Go out of timeout.
if self._connection_state_before_timeout is not None: # sanity check, but it should never be None here
self.setConnectionState(self._connection_state_before_timeout)
@ -361,7 +366,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
self._last_response_time = time()
if self._connection_state == ConnectionState.Connecting:
if self.connectionState == ConnectionState.Connecting:
self.setConnectionState(ConnectionState.Connected)
callback_key = reply.url().toString() + str(reply.operation())
@ -414,6 +419,6 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
@pyqtProperty(str, constant = True)
def ipAddress(self) -> str:
"""IP adress of this printer"""
"""IP address of this printer"""
return self._address

View file

@ -1,11 +1,13 @@
# Copyright (c) 2018 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from enum import IntEnum
from typing import Callable, List, Optional, Union
from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, QTimer, QUrl
from PyQt5.QtWidgets import QMessageBox
import cura.CuraApplication # Imported like this to prevent circular imports.
from UM.Logger import Logger
from UM.Signal import signalemitter
from UM.Qt.QtApplication import QtApplication
@ -120,11 +122,22 @@ class PrinterOutputDevice(QObject, OutputDevice):
callback(QMessageBox.Yes)
def isConnected(self) -> bool:
return self._connection_state != ConnectionState.Closed and self._connection_state != ConnectionState.Error
"""
Returns whether we could theoretically send commands to this printer.
:return: `True` if we are connected, or `False` if not.
"""
return self.connectionState != ConnectionState.Closed and self.connectionState != ConnectionState.Error
def setConnectionState(self, connection_state: "ConnectionState") -> None:
if self._connection_state != connection_state:
"""
Store the connection state of the printer.
Causes everything that displays the connection state to update its QML models.
:param connection_state: The new connection state to store.
"""
if self.connectionState != connection_state:
self._connection_state = connection_state
cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack().setMetaDataEntry("is_online", self.isConnected())
self.connectionStateChanged.emit(self._id)
@pyqtProperty(int, constant = True)
@ -133,6 +146,10 @@ class PrinterOutputDevice(QObject, OutputDevice):
@pyqtProperty(int, notify = connectionStateChanged)
def connectionState(self) -> "ConnectionState":
"""
Get the connection state of the printer, e.g. whether it is connected, still connecting, error state, etc.
:return: The current connection state of this output device.
"""
return self._connection_state
def _update(self) -> None:

View file

@ -0,0 +1,256 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import enum
import functools # For partial methods to use as callbacks with information pre-filled.
import json # To serialise metadata for API calls.
import os # To delete the archive when we're done.
from PyQt5.QtCore import QUrl
import tempfile # To create an archive before we upload it.
import cura.CuraApplication # Imported like this to prevent circular imports.
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To find all printers to upload to.
from cura.UltimakerCloud import UltimakerCloudConstants # To know where the API is.
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To know how to communicate with this server.
from UM.i18n import i18nCatalog
from UM.Job import Job
from UM.Logger import Logger
from UM.Signal import Signal
from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To call the API.
from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
from typing import Any, cast, Dict, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from PyQt5.QtNetwork import QNetworkReply
from cura.UltimakerCloud.CloudMaterialSync import CloudMaterialSync
catalog = i18nCatalog("cura")
class UploadMaterialsError(Exception):
"""
Class to indicate something went wrong while uploading.
"""
pass
class UploadMaterialsJob(Job):
"""
Job that uploads a set of materials to the Digital Factory.
The job has a number of stages:
- First, it generates an archive of all materials. This typically takes a lot of processing power during which the
GIL remains locked.
- Then it requests the API to upload an archive.
- Then it uploads the archive to the URL given by the first request.
- Then it tells the API that the archive can be distributed to the printers.
"""
UPLOAD_REQUEST_URL = f"{UltimakerCloudConstants.CuraCloudAPIRoot}/connect/v1/materials/upload"
UPLOAD_CONFIRM_URL = UltimakerCloudConstants.CuraCloudAPIRoot + "/connect/v1/clusters/{cluster_id}/printers/{cluster_printer_id}/action/import_material"
class Result(enum.IntEnum):
SUCCESS = 0
FAILED = 1
class PrinterStatus(enum.Enum):
UPLOADING = "uploading"
SUCCESS = "success"
FAILED = "failed"
def __init__(self, material_sync: "CloudMaterialSync"):
super().__init__()
self._material_sync = material_sync
self._scope = JsonDecoratorScope(UltimakerCloudScope(cura.CuraApplication.CuraApplication.getInstance())) # type: JsonDecoratorScope
self._archive_filename = None # type: Optional[str]
self._archive_remote_id = None # type: Optional[str] # ID that the server gives to this archive. Used to communicate about the archive to the server.
self._printer_sync_status = {} # type: Dict[str, str]
self._printer_metadata = [] # type: List[Dict[str, Any]]
self.processProgressChanged.connect(self._onProcessProgressChanged)
uploadCompleted = Signal() # Triggered when the job is really complete, including uploading to the cloud.
processProgressChanged = Signal() # Triggered when we've made progress creating the archive.
uploadProgressChanged = Signal() # Triggered when we've made progress with the complete job. This signal emits a progress fraction (0-1) as well as the status of every printer.
def run(self) -> None:
"""
Generates an archive of materials and starts uploading that archive to the cloud.
"""
self._printer_metadata = CuraContainerRegistry.getInstance().findContainerStacksMetadata(
type = "machine",
connection_type = "3", # Only cloud printers.
is_online = "True", # Only online printers. Otherwise the server gives an error.
host_guid = "*", # Required metadata field. Otherwise we get a KeyError.
um_cloud_cluster_id = "*" # Required metadata field. Otherwise we get a KeyError.
)
for printer in self._printer_metadata:
self._printer_sync_status[printer["host_guid"]] = self.PrinterStatus.UPLOADING.value
try:
archive_file = tempfile.NamedTemporaryFile("wb", delete = False)
archive_file.close()
self._archive_filename = archive_file.name
self._material_sync.exportAll(QUrl.fromLocalFile(self._archive_filename), notify_progress = self.processProgressChanged)
except OSError as e:
Logger.error(f"Failed to create archive of materials to sync with printers: {type(e)} - {e}")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to create archive of materials to sync with printers.")))
return
try:
file_size = os.path.getsize(self._archive_filename)
except OSError as e:
Logger.error(f"Failed to load the archive of materials to sync it with printers: {type(e)} - {e}")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to load the archive of materials to sync it with printers.")))
return
request_metadata = {
"data": {
"file_size": file_size,
"material_profile_name": "cura.umm", # File name can be anything as long as it's .umm. It's not used by anyone.
"content_type": "application/zip", # This endpoint won't receive files of different MIME types.
"origin": "cura" # Some identifier against hackers intercepting this upload request, apparently.
}
}
request_payload = json.dumps(request_metadata).encode("UTF-8")
http = HttpRequestManager.getInstance()
http.put(
url = self.UPLOAD_REQUEST_URL,
data = request_payload,
callback = self.onUploadRequestCompleted,
error_callback = self.onError,
scope = self._scope
)
def onUploadRequestCompleted(self, reply: "QNetworkReply") -> None:
"""
Triggered when we successfully requested to upload a material archive.
We then need to start uploading the material archive to the URL that the request answered with.
:param reply: The reply from the server to our request to upload an archive.
"""
response_data = HttpRequestManager.readJSON(reply)
if response_data is None:
Logger.error(f"Invalid response to material upload request. Could not parse JSON data.")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory appears to be corrupted.")))
return
if "data" not in response_data:
Logger.error(f"Invalid response to material upload request: Missing 'data' field that contains the entire response.")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information.")))
return
if "upload_url" not in response_data["data"]:
Logger.error(f"Invalid response to material upload request: Missing 'upload_url' field to upload archive to.")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information.")))
return
if "material_profile_id" not in response_data["data"]:
Logger.error(f"Invalid response to material upload request: Missing 'material_profile_id' to communicate about the materials with the server.")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information.")))
return
upload_url = response_data["data"]["upload_url"]
self._archive_remote_id = response_data["data"]["material_profile_id"]
try:
with open(cast(str, self._archive_filename), "rb") as f:
file_data = f.read()
except OSError as e:
Logger.error(f"Failed to load archive back in for sending to cloud: {type(e)} - {e}")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to load the archive of materials to sync it with printers.")))
return
http = HttpRequestManager.getInstance()
http.put(
url = upload_url,
data = file_data,
callback = self.onUploadCompleted,
error_callback = self.onError,
scope = self._scope
)
def onUploadCompleted(self, reply: "QNetworkReply") -> None:
"""
When we've successfully uploaded the archive to the cloud, we need to notify the API to start syncing that
archive to every printer.
:param reply: The reply from the cloud storage when the upload succeeded.
"""
for container_stack in self._printer_metadata:
cluster_id = container_stack["um_cloud_cluster_id"]
printer_id = container_stack["host_guid"]
http = HttpRequestManager.getInstance()
http.post(
url = self.UPLOAD_CONFIRM_URL.format(cluster_id = cluster_id, cluster_printer_id = printer_id),
callback = functools.partial(self.onUploadConfirmed, printer_id),
error_callback = functools.partial(self.onUploadConfirmed, printer_id), # Let this same function handle the error too.
scope = self._scope,
data = json.dumps({"data": {"material_profile_id": self._archive_remote_id}}).encode("UTF-8")
)
def onUploadConfirmed(self, printer_id: str, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"] = None) -> None:
"""
Triggered when we've got a confirmation that the material is synced with the printer, or that syncing failed.
If syncing succeeded we mark this printer as having the status "success". If it failed we mark the printer as
"failed". If this is the last upload that needed to be completed, we complete the job with either a success
state (every printer successfully synced) or a failed state (any printer failed).
:param printer_id: The printer host_guid that we completed syncing with.
:param reply: The reply that the server gave to confirm.
:param error: If the request failed, this error gives an indication what happened.
"""
if error is not None:
Logger.error(f"Failed to confirm uploading material archive to printer {printer_id}: {error}")
self._printer_sync_status[printer_id] = self.PrinterStatus.FAILED.value
else:
self._printer_sync_status[printer_id] = self.PrinterStatus.SUCCESS.value
still_uploading = len([val for val in self._printer_sync_status.values() if val == self.PrinterStatus.UPLOADING.value])
self.uploadProgressChanged.emit(0.8 + (len(self._printer_sync_status) - still_uploading) / len(self._printer_sync_status), self.getPrinterSyncStatus())
if still_uploading == 0: # This is the last response to be processed.
if self.PrinterStatus.FAILED.value in self._printer_sync_status.values():
self.setResult(self.Result.FAILED)
self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory to sync materials with some of the printers.")))
else:
self.setResult(self.Result.SUCCESS)
self.uploadCompleted.emit(self.getResult(), self.getError())
def onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None:
"""
Used as callback from HTTP requests when the request failed.
The given network error from the `HttpRequestManager` is logged, and the job is marked as failed.
:param reply: The main reply of the server. This reply will most likely not be valid.
:param error: The network error (Qt's enum) that occurred.
"""
Logger.error(f"Failed to upload material archive: {error}")
self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory.")))
def getPrinterSyncStatus(self) -> Dict[str, str]:
"""
For each printer, identified by host_guid, this gives the current status of uploading the material archive.
The possible states are given in the PrinterStatus enum.
:return: A dictionary with printer host_guids as keys, and their status as values.
"""
return self._printer_sync_status
def failed(self, error: UploadMaterialsError) -> None:
"""
Helper function for when we have a general failure.
This sets the sync status for all printers to failed, sets the error on
the job and the result of the job to FAILED.
:param error: An error to show to the user.
"""
self.setResult(self.Result.FAILED)
self.setError(error)
for printer_id in self._printer_sync_status:
self._printer_sync_status[printer_id] = self.PrinterStatus.FAILED.value
self.uploadProgressChanged.emit(1.0, self.getPrinterSyncStatus())
self.uploadCompleted.emit(self.getResult(), self.getError())
def _onProcessProgressChanged(self, progress: float) -> None:
"""
When we progress in the process of uploading materials, we not only signal the new progress (float from 0 to 1)
but we also signal the current status of every printer. These are emitted as the two parameters of the signal.
:param progress: The progress of this job, between 0 and 1.
"""
self.uploadProgressChanged.emit(progress * 0.8, self.getPrinterSyncStatus()) # The processing is 80% of the progress bar.

View file

@ -23,6 +23,8 @@ from UM.Settings.InstanceContainer import InstanceContainer
import cura.CuraApplication
from cura.Machines.ContainerTree import ContainerTree
from cura.Settings.ExtruderStack import ExtruderStack
from cura.Settings.GlobalStack import GlobalStack
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
@ -319,7 +321,7 @@ class ContainerManager(QObject):
stack.qualityChanges = quality_changes
if not quality_changes or container_registry.isReadOnly(quality_changes.getId()):
Logger.log("e", "Could not update quality of a nonexistant or read only quality profile in stack %s", stack.getId())
Logger.log("e", "Could not update quality of a nonexistent or read only quality profile in stack %s", stack.getId())
continue
self._performMerge(quality_changes, stack.getTop())
@ -408,7 +410,7 @@ class ContainerManager(QObject):
container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
for plugin_id, container_type in container_registry.getContainerTypes():
# Ignore default container types since those are not plugins
if container_type in (InstanceContainer, ContainerStack, DefinitionContainer):
if container_type in (InstanceContainer, ContainerStack, DefinitionContainer, GlobalStack, ExtruderStack):
continue
serialize_type = ""

View file

@ -32,6 +32,10 @@ from cura.Machines.ContainerTree import ContainerTree
from cura.ReaderWriters.ProfileReader import NoProfileException, ProfileReader
from UM.i18n import i18nCatalog
from .DatabaseHandlers.IntentDatabaseHandler import IntentDatabaseHandler
from .DatabaseHandlers.QualityDatabaseHandler import QualityDatabaseHandler
from .DatabaseHandlers.VariantDatabaseHandler import VariantDatabaseHandler
catalog = i18nCatalog("cura")
@ -44,6 +48,10 @@ class CuraContainerRegistry(ContainerRegistry):
# is added, we check to see if an extruder stack needs to be added.
self.containerAdded.connect(self._onContainerAdded)
self._database_handlers["variant"] = VariantDatabaseHandler()
self._database_handlers["quality"] = QualityDatabaseHandler()
self._database_handlers["intent"] = IntentDatabaseHandler()
@override(ContainerRegistry)
def addContainer(self, container: ContainerInterface) -> bool:
"""Overridden from ContainerRegistry

View file

@ -66,7 +66,7 @@ class CuraStackBuilder:
Logger.logException("e", "Failed to create an extruder stack for position {pos}: {err}".format(pos = position, err = str(e)))
return None
# If given, set the machine_extruder_count when creating the machine, or else the extruderList used bellow will
# If given, set the machine_extruder_count when creating the machine, or else the extruderList used below will
# not return the correct extruder list (since by default, the machine_extruder_count is 1) in machines with
# settable number of extruders.
if machine_extruder_count and 0 <= machine_extruder_count <= len(extruder_dict):

View file

@ -0,0 +1,25 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.Settings.SQLQueryFactory import SQLQueryFactory
from UM.Settings.DatabaseContainerMetadataController import DatabaseMetadataContainerController
from UM.Settings.InstanceContainer import InstanceContainer
class IntentDatabaseHandler(DatabaseMetadataContainerController):
"""The Database handler for Intent containers"""
def __init__(self) -> None:
super().__init__(SQLQueryFactory(table = "intent",
fields = {
"id": "text",
"name": "text",
"quality_type": "text",
"intent_category": "text",
"variant": "text",
"definition": "text",
"material": "text",
"version": "text",
"setting_version": "text"
}))
self._container_type = InstanceContainer

View file

@ -0,0 +1,38 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.Settings.SQLQueryFactory import SQLQueryFactory, metadata_type
from UM.Settings.DatabaseContainerMetadataController import DatabaseMetadataContainerController
from UM.Settings.InstanceContainer import InstanceContainer
class QualityDatabaseHandler(DatabaseMetadataContainerController):
"""The Database handler for Quality containers"""
def __init__(self):
super().__init__(SQLQueryFactory(table = "quality",
fields = {
"id": "text",
"name": "text",
"quality_type": "text",
"material": "text",
"variant": "text",
"global_quality": "bool",
"definition": "text",
"version": "text",
"setting_version": "text"
}))
self._container_type = InstanceContainer
def groomMetadata(self, metadata: metadata_type) -> metadata_type:
"""
Ensures that the metadata is in the order of the field keys and has the right size.
if the metadata doesn't contains a key which is stored in the DB it will add it as
an empty string. Key, value pairs that are not stored in the DB are dropped.
If the `global_quality` isn't set it well default to 'False'
:param metadata: The container metadata
"""
if "global_quality" not in metadata:
metadata["global_quality"] = "False"
return super().groomMetadata(metadata)

View file

@ -0,0 +1,22 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.Settings.SQLQueryFactory import SQLQueryFactory
from UM.Settings.DatabaseContainerMetadataController import DatabaseMetadataContainerController
from UM.Settings.InstanceContainer import InstanceContainer
class VariantDatabaseHandler(DatabaseMetadataContainerController):
"""The Database handler for Variant containers"""
def __init__(self):
super().__init__(SQLQueryFactory(table = "variant",
fields = {
"id": "text",
"name": "text",
"hardware_type": "text",
"definition": "text",
"version": "text",
"setting_version": "text"
}))
self._container_type = InstanceContainer

View file

@ -1,4 +1,4 @@
# Copyright (c) 2020 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import time
@ -627,7 +627,7 @@ class MachineManager(QObject):
return ""
return global_container_stack.getIntentCategory()
# Provies a list of extruder positions that have a different intent from the active one.
# Provides a list of extruder positions that have a different intent from the active one.
@pyqtProperty("QStringList", notify=activeIntentChanged)
def extruderPositionsWithNonActiveIntent(self):
global_container_stack = self._application.getGlobalContainerStack()
@ -855,7 +855,6 @@ class MachineManager(QObject):
caution_message = Message(
catalog.i18nc("@info:message Followed by a list of settings.",
"Settings have been changed to match the current availability of extruders:") + " [{settings_list}]".format(settings_list = ", ".join(add_user_changes)),
lifetime = 0,
title = catalog.i18nc("@info:title", "Settings updated"))
caution_message.show()
@ -1191,7 +1190,7 @@ class MachineManager(QObject):
self.setIntentByCategory(quality_changes_group.intent_category)
self._reCalculateNumUserSettings()
self.correctExtruderSettings()
self.activeQualityGroupChanged.emit()
self.activeQualityChangesGroupChanged.emit()
@ -1398,6 +1397,8 @@ class MachineManager(QObject):
# previous one).
self._global_container_stack.setUserChanges(global_user_changes)
for i, user_changes in enumerate(per_extruder_user_changes):
if i >= len(self._global_container_stack.extruderList): # New printer has fewer extruders.
break
self._global_container_stack.extruderList[i].setUserChanges(per_extruder_user_changes[i])
@pyqtSlot(QObject)

View file

@ -61,6 +61,10 @@ class SettingInheritanceManager(QObject):
result.append(key)
return result
@pyqtSlot(str, str, result = bool)
def hasOverrides(self, key: str, extruder_index: str):
return key in self.getOverridesForExtruder(key, extruder_index)
@pyqtSlot(str, str, result = "QStringList")
def getOverridesForExtruder(self, key: str, extruder_index: str) -> List[str]:
if self._global_container_stack is None:

View file

@ -18,6 +18,8 @@ class SingleInstance:
self._single_instance_server = None
self._application.getPreferences().addPreference("cura/single_instance_clear_before_load", True)
# Starts a client that checks for a single instance server and sends the files that need to opened if the server
# exists. Returns True if the single instance server is found, otherwise False.
def startClient(self) -> bool:
@ -42,8 +44,9 @@ class SingleInstance:
# "command" field is required and holds the name of the command to execute.
# Other fields depend on the command.
payload = {"command": "clear-all"}
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii"))
if self._application.getPreferences().getValue("cura/single_instance_clear_before_load"):
payload = {"command": "clear-all"}
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii"))
payload = {"command": "focus"}
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii"))
@ -68,7 +71,7 @@ class SingleInstance:
Logger.log("e", "Single instance server was not created.")
def _onClientConnected(self) -> None:
Logger.log("i", "New connection recevied on our single-instance server")
Logger.log("i", "New connection received on our single-instance server")
connection = None #type: Optional[QLocalSocket]
if self._single_instance_server:
connection = self._single_instance_server.nextPendingConnection()

View file

@ -56,8 +56,8 @@ class OnExitCallbackManager:
self._application.callLater(self._application.closeApplication)
# This is the callback function which an on-exit callback should call when it finishes, it should provide the
# "should_proceed" flag indicating whether this check has "passed", or in other words, whether quiting the
# application should be blocked. If the last on-exit callback doesn't block the quiting, it will call the next
# "should_proceed" flag indicating whether this check has "passed", or in other words, whether quitting the
# application should be blocked. If the last on-exit callback doesn't block the quitting, it will call the next
# registered on-exit callback if available.
def onCurrentCallbackFinished(self, should_proceed: bool = True) -> None:
if not should_proceed:

View file

@ -90,7 +90,7 @@ class ObjectsModel(ListModel):
parent = node.getParent()
if parent and parent.callDecoration("isGroup"):
return False # Grouped nodes don't need resetting as their parent (the group) is resetted)
return False # Grouped nodes don't need resetting as their parent (the group) is reset)
node_build_plate_number = node.callDecoration("getBuildPlateNumber")
if Application.getInstance().getPreferences().getValue("view/filter_current_build_plate") and node_build_plate_number != self._build_plate_number:

View file

@ -13,6 +13,8 @@ from UM.Qt.Duration import Duration
from UM.Scene.SceneNode import SceneNode
from UM.i18n import i18nCatalog
from UM.MimeTypeDatabase import MimeTypeDatabase, MimeTypeNotFoundError
from UM.OutputDevice.OutputDevice import OutputDevice
from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
@ -68,6 +70,7 @@ class PrintInformation(QObject):
self._application.globalContainerStackChanged.connect(self.setToZeroPrintInformation)
self._application.fileLoaded.connect(self.setBaseName)
self._application.workspaceLoaded.connect(self.setProjectName)
self._application.getOutputDeviceManager().writeStarted.connect(self._onOutputStart)
self._application.getMachineManager().rootMaterialChanged.connect(self._onActiveMaterialsChanged)
self._application.getInstance().getPreferences().preferenceChanged.connect(self._onPreferencesChanged)
@ -439,3 +442,11 @@ class PrintInformation(QObject):
"""Listen to scene changes to check if we need to reset the print information"""
self.setToZeroPrintInformation(self._active_build_plate)
def _onOutputStart(self, output_device: OutputDevice) -> None:
"""If this is the sort of output 'device' (like local or online file storage, rather than a printer),
the user could have altered the file-name, and thus the project name should be altered as well."""
if isinstance(output_device, ProjectOutputDevice):
new_name = output_device.getLastOutputName()
if new_name is not None:
self.setJobName(os.path.splitext(os.path.basename(new_name))[0])

View file

@ -0,0 +1,217 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl
from PyQt5.QtGui import QDesktopServices
from typing import Dict, Optional, TYPE_CHECKING
import zipfile # To export all materials in a .zip archive.
import cura.CuraApplication # Imported like this to prevent circular imports.
from UM.Resources import Resources
from cura.PrinterOutput.UploadMaterialsJob import UploadMaterialsJob, UploadMaterialsError # To export materials to the output printer.
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
from UM.i18n import i18nCatalog
from UM.Logger import Logger
from UM.Message import Message
if TYPE_CHECKING:
from UM.Signal import Signal
catalog = i18nCatalog("cura")
class CloudMaterialSync(QObject):
"""
Handles the synchronisation of material profiles with cloud accounts.
"""
def __init__(self, parent: QObject = None):
super().__init__(parent)
self.sync_all_dialog = None # type: Optional[QObject]
self._export_upload_status = "idle"
self._checkIfNewMaterialsWereInstalled()
self._export_progress = 0.0
self._printer_status = {} # type: Dict[str, str]
def _checkIfNewMaterialsWereInstalled(self) -> None:
"""
Checks whether new material packages were installed in the latest startup. If there were, then it shows
a message prompting the user to sync the materials with their printers.
"""
application = cura.CuraApplication.CuraApplication.getInstance()
for package_id, package_data in application.getPackageManager().getPackagesInstalledOnStartup().items():
if package_data["package_info"]["package_type"] == "material":
# At least one new material was installed
self._showSyncNewMaterialsMessage()
break
def openSyncAllWindow(self):
self.reset()
if self.sync_all_dialog is None:
qml_path = Resources.getPath(cura.CuraApplication.CuraApplication.ResourceTypes.QmlFiles, "Preferences",
"Materials", "MaterialsSyncDialog.qml")
self.sync_all_dialog = cura.CuraApplication.CuraApplication.getInstance().createQmlComponent(
qml_path, {})
if self.sync_all_dialog is None: # Failed to load QML file.
return
self.sync_all_dialog.setProperty("syncModel", self)
self.sync_all_dialog.setProperty("pageIndex", 0) # Return to first page.
self.sync_all_dialog.setProperty("hasExportedUsb", False) # If the user exported USB before, reset that page.
self.sync_all_dialog.show()
def _showSyncNewMaterialsMessage(self) -> None:
sync_materials_message = Message(
text = catalog.i18nc("@action:button",
"Please sync the material profiles with your printers before starting to print."),
title = catalog.i18nc("@action:button", "New materials installed"),
message_type = Message.MessageType.WARNING,
lifetime = 0
)
sync_materials_message.addAction(
"sync",
name = catalog.i18nc("@action:button", "Sync materials with printers"),
icon = "",
description = "Sync your newly installed materials with your printers.",
button_align = Message.ActionButtonAlignment.ALIGN_RIGHT
)
sync_materials_message.addAction(
"learn_more",
name = catalog.i18nc("@action:button", "Learn more"),
icon = "",
description = "Learn more about syncing your newly installed materials with your printers.",
button_align = Message.ActionButtonAlignment.ALIGN_LEFT,
button_style = Message.ActionButtonStyle.LINK
)
sync_materials_message.actionTriggered.connect(self._onSyncMaterialsMessageActionTriggered)
# Show the message only if there are printers that support material export
container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
global_stacks = container_registry.findContainerStacks(type = "machine")
if any([stack.supportsMaterialExport for stack in global_stacks]):
sync_materials_message.show()
def _onSyncMaterialsMessageActionTriggered(self, sync_message: Message, sync_message_action: str):
if sync_message_action == "sync":
self.openSyncAllWindow()
sync_message.hide()
elif sync_message_action == "learn_more":
QDesktopServices.openUrl(QUrl("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-printer-message"))
@pyqtSlot(result = QUrl)
def getPreferredExportAllPath(self) -> QUrl:
"""
Get the preferred path to export materials to.
If there is a removable drive, that should be the preferred path. Otherwise it should be the most recent local
file path.
:return: The preferred path to export all materials to.
"""
cura_application = cura.CuraApplication.CuraApplication.getInstance()
device_manager = cura_application.getOutputDeviceManager()
devices = device_manager.getOutputDevices()
for device in devices:
if device.__class__.__name__ == "RemovableDriveOutputDevice":
return QUrl.fromLocalFile(device.getId())
else: # No removable drives? Use local path.
return cura_application.getDefaultPath("dialog_material_path")
@pyqtSlot(QUrl)
def exportAll(self, file_path: QUrl, notify_progress: Optional["Signal"] = None) -> None:
"""
Export all materials to a certain file path.
:param file_path: The path to export the materials to.
"""
registry = CuraContainerRegistry.getInstance()
# Create empty archive.
try:
archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED)
except OSError as e:
Logger.log("e", f"Can't write to destination {file_path.toLocalFile()}: {type(e)} - {str(e)}")
error_message = Message(
text = catalog.i18nc("@message:text", "Could not save material archive to {}:").format(file_path.toLocalFile()) + " " + str(e),
title = catalog.i18nc("@message:title", "Failed to save material archive"),
message_type = Message.MessageType.ERROR
)
error_message.show()
return
materials_metadata = registry.findInstanceContainersMetadata(type = "material")
for index, metadata in enumerate(materials_metadata):
if notify_progress is not None:
progress = index / len(materials_metadata)
notify_progress.emit(progress)
if metadata["base_file"] != metadata["id"]: # Only process base files.
continue
if metadata["id"] == "empty_material": # Don't export the empty material.
continue
material = registry.findContainers(id = metadata["id"])[0]
suffix = registry.getMimeTypeForContainer(type(material)).preferredSuffix
filename = metadata["id"] + "." + suffix
try:
archive.writestr(filename, material.serialize())
except OSError as e:
Logger.log("e", f"An error has occurred while writing the material \'{metadata['id']}\' in the file \'{filename}\': {e}.")
exportUploadStatusChanged = pyqtSignal()
@pyqtProperty(str, notify = exportUploadStatusChanged)
def exportUploadStatus(self) -> str:
return self._export_upload_status
@pyqtSlot()
def exportUpload(self) -> None:
"""
Export all materials and upload them to the user's account.
"""
self._export_upload_status = "uploading"
self.exportUploadStatusChanged.emit()
job = UploadMaterialsJob(self)
job.uploadProgressChanged.connect(self._onUploadProgressChanged)
job.uploadCompleted.connect(self.exportUploadCompleted)
job.start()
def _onUploadProgressChanged(self, progress: float, printers_status: Dict[str, str]):
self.setExportProgress(progress)
self.setPrinterStatus(printers_status)
def exportUploadCompleted(self, job_result: UploadMaterialsJob.Result, job_error: Optional[Exception]):
if not self.sync_all_dialog: # Shouldn't get triggered before the dialog is open, but better to check anyway.
return
if job_result == UploadMaterialsJob.Result.FAILED:
if isinstance(job_error, UploadMaterialsError):
self.sync_all_dialog.setProperty("syncStatusText", str(job_error))
else: # Could be "None"
self.sync_all_dialog.setProperty("syncStatusText", catalog.i18nc("@text", "Unknown error."))
self._export_upload_status = "error"
else:
self._export_upload_status = "success"
self.exportUploadStatusChanged.emit()
exportProgressChanged = pyqtSignal(float)
def setExportProgress(self, progress: float) -> None:
self._export_progress = progress
self.exportProgressChanged.emit(self._export_progress)
@pyqtProperty(float, fset = setExportProgress, notify = exportProgressChanged)
def exportProgress(self) -> float:
return self._export_progress
printerStatusChanged = pyqtSignal()
def setPrinterStatus(self, new_status: Dict[str, str]) -> None:
self._printer_status = new_status
self.printerStatusChanged.emit()
@pyqtProperty("QVariantMap", fset = setPrinterStatus, notify = printerStatusChanged)
def printerStatus(self) -> Dict[str, str]:
return self._printer_status
def reset(self) -> None:
self.setPrinterStatus({})
self.setExportProgress(0.0)
self._export_upload_status = "idle"
self.exportUploadStatusChanged.emit()

View file

@ -1,9 +1,15 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtNetwork import QNetworkRequest
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestScope import DefaultUserAgentScope
from cura.API import Account
from cura.CuraApplication import CuraApplication
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
from cura.API.Account import Account
class UltimakerCloudScope(DefaultUserAgentScope):
@ -12,7 +18,7 @@ class UltimakerCloudScope(DefaultUserAgentScope):
Also add the user agent headers (see DefaultUserAgentScope).
"""
def __init__(self, application: CuraApplication):
def __init__(self, application: "CuraApplication"):
super().__init__(application)
api = application.getCuraAPI()
self._account = api.account # type: Account