Merge branch 'master' into Rigid3D

This commit is contained in:
Mehmet Sutaş 2022-03-09 20:22:20 +03:00
commit 96c3c48b00
5089 changed files with 173341 additions and 134925 deletions

View file

@ -64,7 +64,7 @@ body:
You can find your log file here:
Windows: `%APPDATA%\cura\<Cura version>\cura.log` or usually `C:\Users\\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
MacOS: `$USER/Library/Application Support/cura/<Cura version>/cura.log`
Ubuntu/Linus: `$USER/.local/share/cura/<Cura version>/cura.log`
Ubuntu/Linux: `$USER/.local/share/cura/<Cura version>/cura.log`
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
- type: checkboxes

8
.gitignore vendored
View file

@ -10,6 +10,8 @@ resources/i18n/en_7S
resources/i18n/x-test
resources/firmware
resources/materials
resources/images/whats_new
resources/texts/whats_new
CuraEngine.exe
LC_MESSAGES
.cache
@ -37,6 +39,7 @@ cura.desktop
#Externally located plug-ins commonly installed by our devs.
plugins/cura-big-flame-graph
plugins/cura-camera-position
plugins/cura-god-mode-plugin
plugins/cura-siemensnx-plugin
plugins/CuraBlenderPlugin
@ -56,6 +59,11 @@ plugins/SettingsGuide
plugins/SettingsGuide2
plugins/SVGToolpathReader
plugins/X3GWriter
plugins/CuraFlatPack
plugins/CuraRemoteSupport
plugins/ModelCutter
plugins/PrintProfileCreator
plugins/MultiPrintPlugin
#Build stuff
CMakeCache.txt

View file

@ -7,5 +7,5 @@ license: "LGPL-3.0"
message: "If you use this software, please cite it using these metadata."
repository-code: "https://github.com/ultimaker/cura/"
title: "Ultimaker Cura"
version: "4.10.0"
...
version: "4.12.0"
...

View file

@ -33,7 +33,7 @@ configure_file(${CMAKE_SOURCE_DIR}/com.ultimaker.cura.desktop.in ${CMAKE_BINARY_
configure_file(cura/CuraVersion.py.in CuraVersion.py @ONLY)
# FIXME: The new FindPython3 finds the system's Python3.6 reather than the Python3.5 that we built for Cura's environment.
# FIXME: The new FindPython3 finds the system's Python3.6 rather than the Python3.5 that we built for Cura's environment.
# So we're using the old method here, with FindPythonInterp for now.
find_package(PythonInterp 3 REQUIRED)

View file

@ -2,7 +2,7 @@ Cura
====
Ultimaker Cura is a state-of-the-art slicer application to prepare your 3D models for printing with a 3D printer. With hundreds of settings and hundreds of community-managed print profiles, Ultimaker Cura is sure to lead your next project to a success.
![Screenshot](screenshot.png)
![Screenshot](cura-logo.PNG)
Logging Issues
------------
@ -34,7 +34,7 @@ Build scripts
-------------
Please check out [cura-build](https://github.com/Ultimaker/cura-build) for detailed building instructions.
If you want to build the entire environment from scratch before building Cura as well, [cura-build-environment](https://github.com/Ultimaker/cura-build) might be a starting point before cura-build. (Again, see cura-build for more details.)
If you want to build the entire environment from scratch before building Cura as well, [cura-build-environment](https://github.com/Ultimaker/cura-build-environment) might be a starting point before cura-build. (Again, see cura-build for more details.)
Running from Source
-------------

View file

@ -4,7 +4,7 @@
include(CTest)
include(CMakeParseArguments)
# FIXME: The new FindPython3 finds the system's Python3.6 reather than the Python3.5 that we built for Cura's environment.
# FIXME: The new FindPython3 finds the system's Python3.6 rather than the Python3.5 that we built for Cura's environment.
# So we're using the old method here, with FindPythonInterp for now.
find_package(PythonInterp 3 REQUIRED)

View file

@ -28,6 +28,6 @@
<image>https://raw.githubusercontent.com/Ultimaker/Cura/master/screenshot.png</image>
</screenshot>
</screenshots>
<url type="homepage">https://ultimaker.com/en/products/cura-software?utm_source=cura&amp;utm_medium=software&amp;utm_campaign=resources</url>
<url type="homepage">https://ultimaker.com/software/ultimaker-cura?utm_source=cura&amp;utm_medium=software&amp;utm_campaign=cura-update-linux</url>
<translation type="gettext">Cura</translation>
</component>

BIN
cura-logo.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

View file

@ -1,15 +1,15 @@
# Copyright (c) 2018 Ultimaker B.V.
# Copyright (c) 2021 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 datetime import datetime
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS
from typing import Any, Optional, Dict, TYPE_CHECKING, Callable
from UM.Logger import Logger
from UM.Message import Message
from UM.i18n import i18nCatalog
from cura.OAuth2.AuthorizationService import AuthorizationService
from cura.OAuth2.Models import OAuth2Settings
from cura.OAuth2.Models import OAuth2Settings, UserProfile
from cura.UltimakerCloud import UltimakerCloudConstants
if TYPE_CHECKING:
@ -46,6 +46,12 @@ class Account(QObject):
loginStateChanged = pyqtSignal(bool)
"""Signal emitted when user logged in or out"""
userProfileChanged = pyqtSignal()
"""Signal emitted when new account information is available."""
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 +65,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"
@ -68,12 +74,14 @@ class Account(QObject):
self._application = application
self._new_cloud_printers_detected = False
self._error_message = None # type: Optional[Message]
self._error_message: Optional[Message] = None
self._logged_in = False
self._user_profile: Optional[UserProfile] = None
self._additional_rights: Dict[str, Any] = {}
self._sync_state = SyncState.IDLE
self._manual_sync_enabled = False
self._update_packages_enabled = False
self._update_packages_action = None # type: Optional[Callable]
self._update_packages_action: Optional[Callable] = None
self._last_sync_str = "-"
self._callback_port = 32118
@ -99,7 +107,7 @@ class Account(QObject):
self._update_timer.setSingleShot(True)
self._update_timer.timeout.connect(self.sync)
self._sync_services = {} # type: Dict[str, int]
self._sync_services: Dict[str, int] = {}
"""contains entries "service_name" : SyncState"""
def initialize(self) -> None:
@ -192,12 +200,17 @@ class Account(QObject):
self._logged_in = logged_in
self.loginStateChanged.emit(logged_in)
if logged_in:
self._authorization_service.getUserProfile(self._onProfileChanged)
self._setManualSyncEnabled(False)
self._sync()
else:
if self._update_timer.isActive():
self._update_timer.stop()
def _onProfileChanged(self, profile: Optional[UserProfile]) -> None:
self._user_profile = profile
self.userProfileChanged.emit()
def _sync(self) -> None:
"""Signals all sync services to start syncing
@ -239,32 +252,28 @@ class Account(QObject):
return
self._authorization_service.startAuthorizationFlow(force_logout_before_login)
@pyqtProperty(str, notify=loginStateChanged)
@pyqtProperty(str, notify = userProfileChanged)
def userName(self):
user_profile = self._authorization_service.getUserProfile()
if not user_profile:
return None
return user_profile.username
if not self._user_profile:
return ""
return self._user_profile.username
@pyqtProperty(str, notify = loginStateChanged)
@pyqtProperty(str, notify = userProfileChanged)
def profileImageUrl(self):
user_profile = self._authorization_service.getUserProfile()
if not user_profile:
return None
return user_profile.profile_image_url
if not self._user_profile:
return ""
return self._user_profile.profile_image_url
@pyqtProperty(str, notify=accessTokenChanged)
def accessToken(self) -> Optional[str]:
return self._authorization_service.getAccessToken()
@pyqtProperty("QVariantMap", notify = loginStateChanged)
@pyqtProperty("QVariantMap", notify = userProfileChanged)
def userProfile(self) -> Optional[Dict[str, Optional[str]]]:
"""None if no user is logged in otherwise the logged in user as a dict containing containing user_id, username and profile_image_url """
user_profile = self._authorization_service.getUserProfile()
if not user_profile:
if not self._user_profile:
return None
return user_profile.__dict__
return self._user_profile.__dict__
@pyqtProperty(str, notify=lastSyncDateTimeChanged)
def lastSyncDateTime(self) -> str:
@ -301,3 +310,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

@ -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.
# ---------
@ -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.9.0"
try:
from cura.CuraVersion import CuraAppName # type: ignore
@ -46,6 +46,10 @@ except ImportError:
# Various convenience flags indicating what kind of Cura build it is.
__ENTERPRISE_VERSION_TYPE = "enterprise"
IsEnterpriseVersion = CuraBuildType.lower() == __ENTERPRISE_VERSION_TYPE
IsAlternateVersion = CuraBuildType.lower() not in [DEFAULT_CURA_BUILD_TYPE, __ENTERPRISE_VERSION_TYPE]
# NOTE: IsAlternateVersion is to make it possibile to have 'non-numbered' versions, at least as presented to the user.
# (Internally, it'll still have some sort of version-number, but the user is never meant to see it in the GUI).
# Warning: This will also change (some of) the icons/splash-screen to the 'work in progress' alternatives!
try:
from cura.CuraVersion import CuraAppDisplayName # type: ignore

View file

@ -40,7 +40,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
machine_width = build_volume.getWidth()
machine_depth = build_volume.getDepth()
build_plate_bounding_box = Box(machine_width * factor, machine_depth * factor)
build_plate_bounding_box = Box(int(machine_width * factor), int(machine_depth * factor))
if fixed_nodes is None:
fixed_nodes = []
@ -91,7 +91,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
if hull_polygon is not None and hull_polygon.getPoints() is not None and len(hull_polygon.getPoints()) > 2: # numpy array has to be explicitly checked against None
for point in hull_polygon.getPoints():
converted_points.append(Point(point[0] * factor, point[1] * factor))
converted_points.append(Point(int(point[0] * factor), int(point[1] * factor)))
item = Item(converted_points)
item.markAsFixedInBin(0)
node_items.append(item)
@ -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()
@ -178,8 +181,7 @@ class Backup:
return extracted
@staticmethod
def _extractArchive(archive: "ZipFile", target_path: str) -> bool:
def _extractArchive(self, archive: "ZipFile", target_path: str) -> bool:
"""Extract the whole archive to the given target path.
:param archive: The archive as ZipFile.
@ -198,11 +200,17 @@ class Backup:
Resources.factoryReset()
Logger.log("d", "Extracting backup to location: %s", target_path)
name_list = archive.namelist()
ignore_string = re.compile("|".join(self.IGNORED_FILES + self.IGNORED_FOLDERS))
for archive_filename in name_list:
if ignore_string.search(archive_filename):
Logger.warning(f"File ({archive_filename}) in archive that doesn't fit current backup policy; ignored.")
continue
try:
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
@ -65,12 +66,13 @@ class BuildVolume(SceneNode):
self._height = 0 # type: float
self._depth = 0 # type: float
self._shape = "" # type: str
self._scale_vector = Vector(1.0, 1.0, 1.0)
self._shader = None
self._origin_mesh = None # type: Optional[MeshData]
self._origin_line_length = 20
self._origin_line_width = 1.5
self._origin_line_width = 1
self._enabled = False
self._grid_mesh = None # type: Optional[MeshData]
@ -289,7 +291,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.
@ -512,6 +514,13 @@ class BuildVolume(SceneNode):
self._disallowed_area_size = max(size, self._disallowed_area_size)
return mb.build()
def _updateScaleFactor(self) -> None:
if not self._global_container_stack:
return
scale_xy = 100.0 / max(100.0, self._global_container_stack.getProperty("material_shrinkage_percentage_xy", "value"))
scale_z = 100.0 / max(100.0, self._global_container_stack.getProperty("material_shrinkage_percentage_z" , "value"))
self._scale_vector = Vector(scale_xy, scale_xy, scale_z)
def rebuild(self) -> None:
"""Recalculates the build volume & disallowed areas."""
@ -553,9 +562,12 @@ class BuildVolume(SceneNode):
self._error_mesh = self._buildErrorMesh(min_w, max_w, min_h, max_h, min_d, max_d, disallowed_area_height)
self._updateScaleFactor()
self._volume_aabb = AxisAlignedBox(
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))
minimum = Vector(min_w, min_h - 1.0, min_d).scale(self._scale_vector),
maximum = Vector(max_w, max_h - self._raft_thickness - self._extra_z_clearance, max_d).scale(self._scale_vector)
)
bed_adhesion_size = self.getEdgeDisallowedSize()
@ -563,15 +575,15 @@ class BuildVolume(SceneNode):
# This is probably wrong in all other cases. TODO!
# The +1 and -1 is added as there is always a bit of extra room required to work properly.
scale_to_max_bounds = AxisAlignedBox(
minimum = Vector(min_w + bed_adhesion_size + 1, min_h, min_d + self._disallowed_area_size - bed_adhesion_size + 1),
maximum = Vector(max_w - bed_adhesion_size - 1, max_h - self._raft_thickness - self._extra_z_clearance, max_d - self._disallowed_area_size + bed_adhesion_size - 1)
minimum = Vector(min_w + bed_adhesion_size + 1, min_h, min_d + self._disallowed_area_size - bed_adhesion_size + 1).scale(self._scale_vector),
maximum = Vector(max_w - bed_adhesion_size - 1, max_h - self._raft_thickness - self._extra_z_clearance, max_d - self._disallowed_area_size + bed_adhesion_size - 1).scale(self._scale_vector)
)
self._application.getController().getScene()._maximum_bounds = scale_to_max_bounds # type: ignore
self.updateNodeBoundaryCheck()
def getBoundingBox(self):
def getBoundingBox(self) -> Optional[AxisAlignedBox]:
return self._volume_aabb
def getRaftThickness(self) -> float:
@ -589,6 +601,7 @@ class BuildVolume(SceneNode):
if self._adhesion_type == "raft":
self._raft_thickness = (
self._global_container_stack.getProperty("raft_base_thickness", "value") +
self._global_container_stack.getProperty("raft_interface_layers", "value") *
self._global_container_stack.getProperty("raft_interface_thickness", "value") +
self._global_container_stack.getProperty("raft_surface_layers", "value") *
self._global_container_stack.getProperty("raft_surface_thickness", "value") +
@ -632,18 +645,18 @@ class BuildVolume(SceneNode):
for extruder in extruders:
extruder.propertyChanged.connect(self._onSettingPropertyChanged)
self._width = self._global_container_stack.getProperty("machine_width", "value")
self._width = self._global_container_stack.getProperty("machine_width", "value") * self._scale_vector.x
machine_height = self._global_container_stack.getProperty("machine_height", "value")
if self._global_container_stack.getProperty("print_sequence", "value") == "one_at_a_time" and len(self._scene_objects) > 1:
self._height = min(self._global_container_stack.getProperty("gantry_height", "value"), machine_height)
if self._height < machine_height:
self._height = min(self._global_container_stack.getProperty("gantry_height", "value") * self._scale_vector.z, machine_height)
if self._height < (machine_height * self._scale_vector.z):
self._build_volume_message.show()
else:
self._build_volume_message.hide()
else:
self._height = self._global_container_stack.getProperty("machine_height", "value")
self._build_volume_message.hide()
self._depth = self._global_container_stack.getProperty("machine_depth", "value")
self._depth = self._global_container_stack.getProperty("machine_depth", "value") * self._scale_vector.y
self._shape = self._global_container_stack.getProperty("machine_shape", "value")
self._updateDisallowedAreas()
@ -677,18 +690,18 @@ class BuildVolume(SceneNode):
if setting_key == "print_sequence":
machine_height = self._global_container_stack.getProperty("machine_height", "value")
if self._application.getGlobalContainerStack().getProperty("print_sequence", "value") == "one_at_a_time" and len(self._scene_objects) > 1:
self._height = min(self._global_container_stack.getProperty("gantry_height", "value"), machine_height)
if self._height < machine_height:
self._height = min(self._global_container_stack.getProperty("gantry_height", "value") * self._scale_vector.z, machine_height)
if self._height < (machine_height * self._scale_vector.z):
self._build_volume_message.show()
else:
self._build_volume_message.hide()
else:
self._height = self._global_container_stack.getProperty("machine_height", "value")
self._height = self._global_container_stack.getProperty("machine_height", "value") * self._scale_vector.z
self._build_volume_message.hide()
update_disallowed_areas = True
# sometimes the machine size or shape settings are adjusted on the active machine, we should reflect this
if setting_key in self._machine_settings:
if setting_key in self._machine_settings or setting_key in self._material_size_settings:
self._updateMachineSizeProperties()
update_extra_z_clearance = True
update_disallowed_areas = True
@ -737,9 +750,10 @@ class BuildVolume(SceneNode):
def _updateMachineSizeProperties(self) -> None:
if not self._global_container_stack:
return
self._height = self._global_container_stack.getProperty("machine_height", "value")
self._width = self._global_container_stack.getProperty("machine_width", "value")
self._depth = self._global_container_stack.getProperty("machine_depth", "value")
self._updateScaleFactor()
self._height = self._global_container_stack.getProperty("machine_height", "value") * self._scale_vector.z
self._width = self._global_container_stack.getProperty("machine_width", "value") * self._scale_vector.x
self._depth = self._global_container_stack.getProperty("machine_depth", "value") * self._scale_vector.y
self._shape = self._global_container_stack.getProperty("machine_shape", "value")
def _updateDisallowedAreasAndRebuild(self):
@ -756,6 +770,14 @@ class BuildVolume(SceneNode):
self._extra_z_clearance = self._calculateExtraZClearance(ExtruderManager.getInstance().getUsedExtruderStacks())
self.rebuild()
def _scaleAreas(self, result_areas: List[Polygon]) -> None:
if self._global_container_stack is None:
return
for i, polygon in enumerate(result_areas):
result_areas[i] = polygon.scale(
100.0 / max(100.0, self._global_container_stack.getProperty("material_shrinkage_percentage_xy", "value"))
)
def _updateDisallowedAreas(self) -> None:
if not self._global_container_stack:
return
@ -811,9 +833,11 @@ class BuildVolume(SceneNode):
self._disallowed_areas = []
for extruder_id in result_areas:
self._scaleAreas(result_areas[extruder_id])
self._disallowed_areas.extend(result_areas[extruder_id])
self._disallowed_areas_no_brim = []
for extruder_id in result_areas_no_brim:
self._scaleAreas(result_areas_no_brim[extruder_id])
self._disallowed_areas_no_brim.extend(result_areas_no_brim[extruder_id])
def _computeDisallowedAreasPrinted(self, used_extruders):
@ -825,10 +849,10 @@ class BuildVolume(SceneNode):
"""
result = {}
adhesion_extruder = None #type: ExtruderStack
skirt_brim_extruder: ExtruderStack = None
for extruder in used_extruders:
if int(extruder.getProperty("extruder_nr", "value")) == int(self._global_container_stack.getProperty("adhesion_extruder_nr", "value")):
adhesion_extruder = extruder
if int(extruder.getProperty("extruder_nr", "value")) == int(self._global_container_stack.getProperty("skirt_brim_extruder_nr", "value")):
skirt_brim_extruder = extruder
result[extruder.getId()] = []
# Currently, the only normally printed object is the prime tower.
@ -842,11 +866,11 @@ class BuildVolume(SceneNode):
prime_tower_x = prime_tower_x - machine_width / 2 #Offset by half machine_width and _depth to put the origin in the front-left.
prime_tower_y = prime_tower_y + machine_depth / 2
if adhesion_extruder is not None and self._global_container_stack.getProperty("prime_tower_brim_enable", "value") and self._global_container_stack.getProperty("adhesion_type", "value") != "raft":
if skirt_brim_extruder is not None and self._global_container_stack.getProperty("prime_tower_brim_enable", "value") and self._global_container_stack.getProperty("adhesion_type", "value") != "raft":
brim_size = (
adhesion_extruder.getProperty("brim_line_count", "value") *
adhesion_extruder.getProperty("skirt_brim_line_width", "value") / 100.0 *
adhesion_extruder.getProperty("initial_layer_line_width_factor", "value")
skirt_brim_extruder.getProperty("brim_line_count", "value") *
skirt_brim_extruder.getProperty("skirt_brim_line_width", "value") / 100.0 *
skirt_brim_extruder.getProperty("initial_layer_line_width_factor", "value")
)
prime_tower_x -= brim_size
prime_tower_y += brim_size
@ -1077,13 +1101,18 @@ class BuildVolume(SceneNode):
# with the adhesion extruder, but it also prints one extra line by all other extruders. As such, the
# 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")
skirt_brim_extruder_nr = self._global_container_stack.getProperty("skirt_brim_extruder_nr", "value")
try:
skirt_brim_stack = self._global_container_stack.extruderList[int(skirt_brim_extruder_nr)]
except IndexError:
Logger.warning(f"Couldn't find extruder with index '{skirt_brim_extruder_nr}', defaulting to 0 instead.")
skirt_brim_stack = self._global_container_stack.extruderList[0]
skirt_brim_line_width = skirt_brim_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 = skirt_brim_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")
brim_line_count = skirt_brim_stack.getProperty("brim_line_count", "value")
bed_adhesion_size = skirt_brim_line_width * brim_line_count * initial_layer_line_width_factor / 100.0
for extruder_stack in used_extruders:
@ -1092,8 +1121,8 @@ class BuildVolume(SceneNode):
# We don't create an additional line for the extruder we're printing the brim with.
bed_adhesion_size -= skirt_brim_line_width * initial_layer_line_width_factor / 100.0
elif adhesion_type == "skirt":
skirt_distance = self._global_container_stack.getProperty("skirt_gap", "value")
skirt_line_count = self._global_container_stack.getProperty("skirt_line_count", "value")
skirt_distance = skirt_brim_stack.getProperty("skirt_gap", "value")
skirt_line_count = skirt_brim_stack.getProperty("skirt_line_count", "value")
bed_adhesion_size = skirt_distance + (
skirt_brim_line_width * skirt_line_count) * initial_layer_line_width_factor / 100.0
@ -1104,7 +1133,7 @@ class BuildVolume(SceneNode):
# We don't create an additional line for the extruder we're printing the skirt with.
bed_adhesion_size -= skirt_brim_line_width * initial_layer_line_width_factor / 100.0
elif adhesion_type == "raft":
bed_adhesion_size = self._global_container_stack.getProperty("raft_margin", "value")
bed_adhesion_size = self._global_container_stack.getProperty("raft_margin", "value") # Should refer to the raft extruder if set.
elif adhesion_type == "none":
bed_adhesion_size = 0
else:
@ -1186,12 +1215,13 @@ class BuildVolume(SceneNode):
_machine_settings = ["machine_width", "machine_depth", "machine_height", "machine_shape", "machine_center_is_zero"]
_skirt_settings = ["adhesion_type", "skirt_gap", "skirt_line_count", "skirt_brim_line_width", "brim_width", "brim_line_count", "raft_margin", "draft_shield_enabled", "draft_shield_dist", "initial_layer_line_width_factor"]
_raft_settings = ["adhesion_type", "raft_base_thickness", "raft_interface_thickness", "raft_surface_layers", "raft_surface_thickness", "raft_airgap", "layer_0_z_overlap"]
_raft_settings = ["adhesion_type", "raft_base_thickness", "raft_interface_layers", "raft_interface_thickness", "raft_surface_layers", "raft_surface_thickness", "raft_airgap", "layer_0_z_overlap"]
_extra_z_settings = ["retraction_hop_enabled", "retraction_hop"]
_prime_settings = ["extruder_prime_pos_x", "extruder_prime_pos_y", "prime_blob_enable"]
_tower_settings = ["prime_tower_enable", "prime_tower_size", "prime_tower_position_x", "prime_tower_position_y", "prime_tower_brim_enable"]
_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", "travel_avoid_supports", "wall_line_count", "wall_line_width_0", "wall_line_width_x"]
_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"]
_disallowed_area_settings = _skirt_settings + _prime_settings + _tower_settings + _ooze_shield_settings + _distance_settings + _extruder_settings
_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", "skirt_brim_extruder_nr", "raft_base_extruder_nr", "raft_interface_extruder_nr", "raft_surface_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", "skirt_brim_extruder_nr", "raft_base_extruder_nr", "raft_interface_extruder_nr", "raft_surface_extruder_nr"]
_material_size_settings = ["material_shrinkage_percentage", "material_shrinkage_percentage_xy", "material_shrinkage_percentage_z"]
_disallowed_area_settings = _skirt_settings + _prime_settings + _tower_settings + _ooze_shield_settings + _distance_settings + _extruder_settings + _material_size_settings

View file

@ -15,7 +15,7 @@ from typing import cast, Any
try:
from sentry_sdk.hub import Hub
from sentry_sdk.utils import event_from_exception
from sentry_sdk import configure_scope
from sentry_sdk import configure_scope, add_breadcrumb
with_sentry_sdk = True
except ImportError:
with_sentry_sdk = False
@ -424,6 +424,13 @@ class CrashHandler:
if with_sentry_sdk:
try:
hub = Hub.current
if not Logger.getLoggers():
# No loggers have been loaded yet, so we don't have any breadcrumbs :(
# So add them manually so we at least have some info...
add_breadcrumb(level = "info", message = "SentryLogging was not initialised yet")
for log_type, line in Logger.getUnloggedLines():
add_breadcrumb(message=line)
event, hint = event_from_exception((self.exception_type, self.value, self.traceback))
hub.capture_event(event, hint=hint)
hub.flush()

View file

@ -35,7 +35,7 @@ class CuraActions(QObject):
# Starting a web browser from a signal handler connected to a menu will crash on windows.
# So instead, defer the call to the next run of the event loop, since that does work.
# Note that weirdly enough, only signal handlers that open a web browser fail like that.
event = CallFunctionEvent(self._openUrl, [QUrl("https://ultimaker.com/en/resources/manuals/software")], {})
event = CallFunctionEvent(self._openUrl, [QUrl("https://ultimaker.com/en/resources/manuals/software?utm_source=cura&utm_medium=software&utm_campaign=dropdown-documentation")], {})
cura.CuraApplication.CuraApplication.getInstance().functionEvent(event)
@pyqtSlot()

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
@ -152,16 +152,17 @@ class CuraApplication(QtApplication):
def __init__(self, *args, **kwargs):
super().__init__(name = ApplicationMetadata.CuraAppName,
app_display_name = ApplicationMetadata.CuraAppDisplayName,
version = ApplicationMetadata.CuraVersion,
version = ApplicationMetadata.CuraVersion if not ApplicationMetadata.IsAlternateVersion else ApplicationMetadata.CuraBuildType,
api_version = ApplicationMetadata.CuraSDKVersion,
build_type = ApplicationMetadata.CuraBuildType,
is_debug_mode = ApplicationMetadata.CuraDebugMode,
tray_icon_name = "cura-icon-32.png",
tray_icon_name = "cura-icon-32.png" if not ApplicationMetadata.IsAlternateVersion else "cura-icon-32_wip.png",
**kwargs)
self.default_theme = "cura-light"
self.change_log_url = "https://ultimaker.com/ultimaker-cura-latest-features"
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,6 +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"),
("extruder", 2): (Resources.DefinitionContainers, "application/x-uranium-definitioncontainer")
}
)
@ -481,7 +484,7 @@ class CuraApplication(QtApplication):
if not self.getIsHeadLess():
try:
self.setWindowIcon(QIcon(Resources.getPath(Resources.Images, "cura-icon.png")))
self.setWindowIcon(QIcon(Resources.getPath(Resources.Images, "cura-icon.png" if not ApplicationMetadata.IsAlternateVersion else "cura-icon_wip.png")))
except FileNotFoundError:
Logger.log("w", "Unable to find the window icon.")
@ -491,7 +494,7 @@ class CuraApplication(QtApplication):
"CuraEngineBackend", #Cura is useless without this one since you can't slice.
"FileLogger", #You want to be able to read the log if something goes wrong.
"XmlMaterialProfile", #Cura crashes without this one.
"Toolbox", #This contains the interface to enable/disable plug-ins, so if you disable it you can't enable it back.
"Marketplace", #This contains the interface to enable/disable plug-ins, so if you disable it you can't enable it back.
"PrepareStage", #Cura is useless without this one since you can't load models.
"PreviewStage", #This shows the list of the plugin views that are installed in Cura.
"MonitorStage", #Major part of Cura's functionality.
@ -570,6 +573,10 @@ class CuraApplication(QtApplication):
preferences.addPreference("general/accepted_user_agreement", False)
preferences.addPreference("cura/market_place_show_plugin_banner", True)
preferences.addPreference("cura/market_place_show_material_banner", True)
preferences.addPreference("cura/market_place_show_manage_packages_banner", True)
for key in [
"dialog_load_path", # dialog_save_path is in LocalFileOutputDevicePlugin
"dialog_profile_path",
@ -672,22 +679,6 @@ class CuraApplication(QtApplication):
self._setLoadingHint(self._i18n_catalog.i18nc("@info:progress", "Initializing Active Machine..."))
super().setGlobalContainerStack(stack)
showMessageBox = pyqtSignal(str,str, str, str, int, int,
arguments = ["title", "text", "informativeText", "detailedText","buttons", "icon"])
"""A reusable dialogbox"""
def messageBox(self, title, text,
informativeText = "",
detailedText = "",
buttons = QMessageBox.Ok,
icon = QMessageBox.NoIcon,
callback = None,
callback_arguments = []
):
self._message_box_callback = callback
self._message_box_callback_arguments = callback_arguments
self.showMessageBox.emit(title, text, informativeText, detailedText, buttons, icon)
showDiscardOrKeepProfileChanges = pyqtSignal()
def discardOrKeepProfileChanges(self) -> bool:
@ -714,6 +705,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
@ -748,7 +740,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):
@ -771,10 +765,14 @@ class CuraApplication(QtApplication):
lib_suffixes = {""}
for suffix in lib_suffixes:
self._plugin_registry.addPluginLocation(os.path.join(QtApplication.getInstallPrefix(), "lib" + suffix, "cura"))
if not hasattr(sys, "frozen"):
self._plugin_registry.addPluginLocation(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "plugins"))
self._plugin_registry.preloaded_plugins.append("ConsoleLogger")
# Since it's possible to get crashes in code before the sentrylogger is loaded, we want to start this plugin
# as quickly as possible, as we might get unsolvable crash reports without it.
self._plugin_registry.preloaded_plugins.append("SentryLogger")
self._plugin_registry.loadPlugins()
if self.getBackend() is None:
@ -1310,9 +1308,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"):
@ -1330,9 +1328,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)
@ -1359,9 +1357,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)
@ -1388,7 +1386,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"):
@ -1416,11 +1414,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
@ -2037,11 +2035,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

@ -1,13 +1,15 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import List, Tuple, TYPE_CHECKING, Optional
from typing import Any, cast, Dict, List, Set, Tuple, TYPE_CHECKING, Optional
from cura.CuraApplication import CuraApplication #To find some resource types.
from cura.CuraApplication import CuraApplication # To find some resource types.
from cura.Settings.GlobalStack import GlobalStack
from UM.PackageManager import PackageManager #The class we're extending.
from UM.Resources import Resources #To find storage paths for some resource types.
from UM.PackageManager import PackageManager # The class we're extending.
from UM.Resources import Resources # To find storage paths for some resource types.
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
if TYPE_CHECKING:
from UM.Qt.QtApplication import QtApplication
@ -17,6 +19,31 @@ if TYPE_CHECKING:
class CuraPackageManager(PackageManager):
def __init__(self, application: "QtApplication", parent: Optional["QObject"] = None) -> None:
super().__init__(application, parent)
self._local_packages: Optional[List[Dict[str, Any]]] = None
self._local_packages_ids: Optional[Set[str]] = None
self.installedPackagesChanged.connect(self._updateLocalPackages)
def _updateLocalPackages(self) -> None:
self._local_packages = self.getAllLocalPackages()
self._local_packages_ids = set(pkg["package_id"] for pkg in self._local_packages)
@property
def local_packages(self) -> List[Dict[str, Any]]:
"""locally installed packages, lazy execution"""
if self._local_packages is None:
self._updateLocalPackages()
# _updateLocalPackages always results in a list of packages, not None.
# It's guaranteed to be a list now.
return cast(List[Dict[str, Any]], self._local_packages)
@property
def local_packages_ids(self) -> Set[str]:
"""locally installed packages, lazy execution"""
if self._local_packages_ids is None:
self._updateLocalPackages()
# _updateLocalPackages always results in a list of packages, not None.
# It's guaranteed to be a list now.
return cast(Set[str], self._local_packages_ids)
def initialize(self) -> None:
self._installation_dirs_dict["materials"] = Resources.getStoragePath(CuraApplication.ResourceTypes.MaterialInstanceContainer)
@ -47,3 +74,12 @@ class CuraPackageManager(PackageManager):
machine_with_qualities.append((global_stack, str(extruder_nr), container_id))
return machine_with_materials, machine_with_qualities
def getAllLocalPackages(self) -> List[Dict[str, Any]]:
""" Returns an unordered list of all the package_info of installed, to be installed, or bundled packages"""
packages: List[Dict[str, Any]] = []
for packages_to_add in self.getAllInstalledPackagesInfo().values():
packages.extend(packages_to_add)
return packages

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

@ -59,7 +59,7 @@ class LayerPolygon:
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 +146,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

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 List, Optional
from UM.Qt.ListModel import ListModel
from UM.i18n import i18nCatalog
@ -10,6 +11,7 @@ from UM.Util import parseBool
from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
from cura.Settings.GlobalStack import GlobalStack
from cura.UltimakerCloud.UltimakerCloudConstants import META_CAPABILITIES # To filter on the printer's capabilities.
class GlobalStacksModel(ListModel):
@ -20,6 +22,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 +34,70 @@ 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
self._filter_capabilities: List[str] = [] # Required capabilities that all listed printers must have.
# 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()
filterCapabilitiesChanged = pyqtSignal()
filterOnlineOnlyChanged = pyqtSignal()
def setFilterConnectionType(self, new_filter: Optional[ConnectionType]) -> None:
if self._filter_connection_type != new_filter:
self._filter_connection_type = new_filter
self.filterConnectionTypeChanged.emit()
@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
def setFilterOnlineOnly(self, new_filter: bool) -> None:
if self._filter_online_only != new_filter:
self._filter_online_only = new_filter
self.filterOnlineOnlyChanged.emit()
@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 setFilterCapabilities(self, new_filter: List[str]) -> None:
if self._filter_capabilities != new_filter:
self._filter_capabilities = new_filter
self.filterCapabilitiesChanged.emit()
@pyqtProperty("QStringList", fset = setFilterCapabilities, notify = filterCapabilitiesChanged)
def filterCapabilities(self) -> List[str]:
"""
Capabilities to require on the list of printers.
Only printers that have all of these capabilities will be shown in this model.
"""
return self._filter_capabilities
def _onContainerChanged(self, container) -> None:
"""Handler for container added/removed events from registry"""
@ -58,6 +113,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 +126,14 @@ 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
capabilities = set(container_stack.getMetaDataEntry(META_CAPABILITIES, "").split(","))
if set(self._filter_capabilities) - capabilities: # Not all required capabilities are met.
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 +149,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

@ -106,11 +106,15 @@ class IntentCategoryModel(ListModel):
for category in available_categories:
qualities = IntentModel()
qualities.setIntentCategory(category)
try:
weight = list(IntentCategoryModel._get_translations().keys()).index(category)
except ValueError:
weight = 99
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),
"weight": weight,
"qualities": qualities
})
result.sort(key = lambda k: k["weight"])

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"),
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

@ -361,8 +361,15 @@ class QualityManagementModel(ListModel):
"section_name": catalog.i18nc("@label", intent_translations.get(intent_category, {}).get("name", catalog.i18nc("@label", "Unknown"))),
})
# Sort by quality_type for each intent category
intent_translations_list = list(intent_translations)
result = sorted(result, key = lambda x: (list(intent_translations).index(x["intent_category"]), x["quality_type"]))
def getIntentWeight(intent_category):
try:
return intent_translations_list.index(intent_category)
except ValueError:
return 99
result = sorted(result, key = lambda x: (getIntentWeight(x["intent_category"]), x["quality_type"]))
item_list += result
# Create quality_changes group items

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

@ -1,4 +1,4 @@
# Copyright (c) 2020 Ultimaker B.V.
# Copyright (c) 2022 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, Qt
@ -9,6 +9,7 @@ from UM import i18nCatalog
from UM.Logger import Logger
from UM.Qt.ListModel import ListModel
from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.Settings.SettingFunction import SettingFunction # To format setting functions differently.
import os
@ -173,12 +174,22 @@ class QualitySettingsModel(ListModel):
label = definition.label
if self._i18n_catalog:
label = self._i18n_catalog.i18nc(definition.key + " label", label)
if profile_value_source == "quality_changes":
label = f"<i>{label}</i>" # Make setting name italic if it's derived from the quality-changes profile.
if isinstance(profile_value, SettingFunction):
if self._i18n_catalog:
profile_value_display = self._i18n_catalog.i18nc("@info:status", "Calculated")
else:
profile_value_display = "Calculated"
else:
profile_value_display = "" if profile_value is None else str(profile_value)
items.append({
"key": definition.key,
"label": label,
"unit": definition.unit,
"profile_value": "" if profile_value is None else str(profile_value), # it is for display only
"profile_value": profile_value_display,
"profile_value_source": profile_value_source,
"user_value": "" if user_value is None else str(user_value),
"category": current_category

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

@ -1,18 +1,19 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime
import json
import random
from hashlib import sha512
from base64 import b64encode
from typing import Optional
import requests
from UM.i18n import i18nCatalog
from UM.Logger import Logger
from datetime import datetime
from hashlib import sha512
from PyQt5.QtNetwork import QNetworkReply
import secrets
from typing import Callable, Optional
import urllib.parse
from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settings
from UM.i18n import i18nCatalog
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To download log-in tokens.
catalog = i18nCatalog("cura")
TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
@ -30,14 +31,13 @@ class AuthorizationHelpers:
return self._settings
def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str) -> "AuthenticationResponse":
"""Request the access token from the authorization server.
def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str, callback: Callable[[AuthenticationResponse], None]) -> None:
"""
Request the access token from the authorization server.
:param authorization_code: The authorization code from the 1st step.
:param verification_code: The verification code needed for the PKCE extension.
:return: An AuthenticationResponse object.
:param callback: Once the token has been obtained, this function will be called with the response.
"""
data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
"redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "",
@ -46,18 +46,21 @@ class AuthorizationHelpers:
"code_verifier": verification_code,
"scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "",
}
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")
headers = {"Content-type": "application/x-www-form-urlencoded"}
HttpRequestManager.getInstance().post(
self._token_url,
data = urllib.parse.urlencode(data).encode("UTF-8"),
headers_dict = headers,
callback = lambda response: self.parseTokenResponse(response, callback),
error_callback = lambda response, _: self.parseTokenResponse(response, callback)
)
def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse":
"""Request the access token from the authorization server using a refresh token.
:param refresh_token:
:return: An AuthenticationResponse object.
def getAccessTokenUsingRefreshToken(self, refresh_token: str, callback: Callable[[AuthenticationResponse], None]) -> None:
"""
Request the access token from the authorization server using a refresh token.
:param refresh_token: A long-lived token used to refresh the authentication token.
:param callback: Once the token has been obtained, this function will be called with the response.
"""
Logger.log("d", "Refreshing the access token for [%s]", self._settings.OAUTH_SERVER_URL)
data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
@ -66,84 +69,108 @@ class AuthorizationHelpers:
"refresh_token": refresh_token,
"scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "",
}
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 OSError as e:
return AuthenticationResponse(success = False, err_message = "Operating system is unable to set up a secure connection: {err}".format(err = str(e)))
headers = {"Content-type": "application/x-www-form-urlencoded"}
HttpRequestManager.getInstance().post(
self._token_url,
data = urllib.parse.urlencode(data).encode("UTF-8"),
headers_dict = headers,
callback = lambda response: self.parseTokenResponse(response, callback),
error_callback = lambda response, _: self.parseTokenResponse(response, callback)
)
@staticmethod
def parseTokenResponse(token_response: requests.models.Response) -> "AuthenticationResponse":
def parseTokenResponse(self, token_response: QNetworkReply, callback: Callable[[AuthenticationResponse], None]) -> None:
"""Parse the token response from the authorization server into an AuthenticationResponse object.
:param token_response: The JSON string data response from the authorization server.
:return: An AuthenticationResponse object.
"""
token_data = None
try:
token_data = json.loads(token_response.text)
except ValueError:
Logger.log("w", "Could not parse token response data: %s", token_response.text)
token_data = HttpRequestManager.readJSON(token_response)
if not token_data:
return AuthenticationResponse(success = False, err_message = catalog.i18nc("@message", "Could not read response."))
callback(AuthenticationResponse(success = False, err_message = catalog.i18nc("@message", "Could not read response.")))
return
if token_response.status_code not in (200, 201):
return AuthenticationResponse(success = False, err_message = token_data["error_description"])
if token_response.error() != QNetworkReply.NetworkError.NoError:
callback(AuthenticationResponse(success = False, err_message = token_data["error_description"]))
return
return AuthenticationResponse(success=True,
token_type=token_data["token_type"],
access_token=token_data["access_token"],
refresh_token=token_data["refresh_token"],
expires_in=token_data["expires_in"],
scope=token_data["scope"],
received_at=datetime.now().strftime(TOKEN_TIMESTAMP_FORMAT))
callback(AuthenticationResponse(success = True,
token_type = token_data["token_type"],
access_token = token_data["access_token"],
refresh_token = token_data["refresh_token"],
expires_in = token_data["expires_in"],
scope = token_data["scope"],
received_at = datetime.now().strftime(TOKEN_TIMESTAMP_FORMAT)))
return
def parseJWT(self, access_token: str) -> Optional["UserProfile"]:
def checkToken(self, access_token: str, success_callback: Optional[Callable[[UserProfile], None]] = None, failed_callback: Optional[Callable[[], None]] = None) -> None:
"""Calls the authentication API endpoint to get the token data.
The API is called asynchronously. When a response is given, the callback is called with the user's profile.
:param access_token: The encoded JWT token.
:return: Dict containing some profile data.
:param success_callback: When a response is given, this function will be called with a user profile. If None,
there will not be a callback.
:param failed_callback: When the request failed or the response didn't parse, this function will be called.
"""
try:
check_token_url = "{}/check-token".format(self._settings.OAUTH_SERVER_URL)
Logger.log("d", "Checking the access token for [%s]", check_token_url)
token_request = requests.get(check_token_url, headers = {
"Authorization": "Bearer {}".format(access_token)
})
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
# Connection was suddenly dropped. Nothing we can do about that.
Logger.logException("w", "Something failed while attempting to parse the JWT token")
return None
if token_request.status_code not in (200, 201):
Logger.log("w", "Could not retrieve token data from auth server: %s", token_request.text)
return None
user_data = token_request.json().get("data")
if not user_data or not isinstance(user_data, dict):
Logger.log("w", "Could not parse user data from token: %s", user_data)
return None
return UserProfile(
user_id = user_data["user_id"],
username = user_data["username"],
profile_image_url = user_data.get("profile_image_url", ""),
organization_id = user_data.get("organization", {}).get("organization_id"),
subscriptions = user_data.get("subscriptions", [])
check_token_url = "{}/check-token".format(self._settings.OAUTH_SERVER_URL)
Logger.log("d", "Checking the access token for [%s]", check_token_url)
headers = {
"Authorization": f"Bearer {access_token}"
}
HttpRequestManager.getInstance().get(
check_token_url,
headers_dict = headers,
callback = lambda reply: self._parseUserProfile(reply, success_callback, failed_callback),
error_callback = lambda _, _2: failed_callback() if failed_callback is not None else None
)
def _parseUserProfile(self, reply: QNetworkReply, success_callback: Optional[Callable[[UserProfile], None]], failed_callback: Optional[Callable[[], None]] = None) -> None:
"""
Parses the user profile from a reply to /check-token.
If the response is valid, the callback will be called to return the user profile to the caller.
:param reply: A network reply to a request to the /check-token URL.
:param success_callback: A function to call once a user profile was successfully obtained.
:param failed_callback: A function to call if parsing the profile failed.
"""
if reply.error() != QNetworkReply.NetworkError.NoError:
Logger.warning(f"Could not access account information. QNetworkError {reply.errorString()}")
if failed_callback is not None:
failed_callback()
return
profile_data = HttpRequestManager.getInstance().readJSON(reply)
if profile_data is None or "data" not in profile_data:
Logger.warning("Could not parse user data from token.")
if failed_callback is not None:
failed_callback()
return
profile_data = profile_data["data"]
required_fields = {"user_id", "username"}
if "user_id" not in profile_data or "username" not in profile_data:
Logger.warning(f"User data missing required field(s): {required_fields - set(profile_data.keys())}")
if failed_callback is not None:
failed_callback()
return
if success_callback is not None:
success_callback(UserProfile(
user_id = profile_data["user_id"],
username = profile_data["username"],
profile_image_url = profile_data.get("profile_image_url", ""),
organization_id = profile_data.get("organization", {}).get("organization_id"),
subscriptions = profile_data.get("subscriptions", [])
))
@staticmethod
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

@ -2,6 +2,7 @@
# Cura is released under the terms of the LGPLv3 or higher.
from http.server import BaseHTTPRequestHandler
from threading import Lock # To turn an asynchronous call synchronous.
from typing import Optional, Callable, Tuple, Dict, Any, List, TYPE_CHECKING
from urllib.parse import parse_qs, urlparse
@ -14,6 +15,7 @@ if TYPE_CHECKING:
catalog = i18nCatalog("cura")
class AuthorizationRequestHandler(BaseHTTPRequestHandler):
"""This handler handles all HTTP requests on the local web server.
@ -24,11 +26,11 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
super().__init__(request, client_address, server)
# These values will be injected by the HTTPServer that this handler belongs to.
self.authorization_helpers = None # type: Optional[AuthorizationHelpers]
self.authorization_callback = None # type: Optional[Callable[[AuthenticationResponse], None]]
self.verification_code = None # type: Optional[str]
self.authorization_helpers: Optional[AuthorizationHelpers] = None
self.authorization_callback: Optional[Callable[[AuthenticationResponse], None]] = None
self.verification_code: Optional[str] = None
self.state = None # type: Optional[str]
self.state: Optional[str] = None
# CURA-6609: Some browser seems to issue a HEAD instead of GET request as the callback.
def do_HEAD(self) -> None:
@ -70,13 +72,23 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
if state != self.state:
token_response = AuthenticationResponse(
success = False,
err_message=catalog.i18nc("@message",
"The provided state is not correct.")
err_message = catalog.i18nc("@message", "The provided state is not correct.")
)
elif code and self.authorization_helpers is not None and self.verification_code is not None:
token_response = AuthenticationResponse(
success = False,
err_message = catalog.i18nc("@message", "Timeout when authenticating with the account server.")
)
# If the code was returned we get the access token.
token_response = self.authorization_helpers.getAccessTokenUsingAuthorizationCode(
code, self.verification_code)
lock = Lock()
lock.acquire()
def callback(response: AuthenticationResponse) -> None:
nonlocal token_response
token_response = response
lock.release()
self.authorization_helpers.getAccessTokenUsingAuthorizationCode(code, self.verification_code, callback)
lock.acquire(timeout = 60) # Block thread until request is completed (which releases the lock). If not acquired, the timeout message stays.
elif self._queryGet(query, "error_code") == "user_denied":
# Otherwise we show an error message (probably the user clicked "Deny" in the auth dialog).

View file

@ -3,10 +3,9 @@
import json
from datetime import datetime, timedelta
from typing import Optional, TYPE_CHECKING, Dict
from typing import Callable, Dict, Optional, TYPE_CHECKING, Union
from urllib.parse import urlencode, quote_plus
import requests.exceptions
from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QDesktopServices
@ -16,7 +15,7 @@ from UM.Signal import Signal
from UM.i18n import i18nCatalog
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT
from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
from cura.OAuth2.Models import AuthenticationResponse
from cura.OAuth2.Models import AuthenticationResponse, BaseModel
i18n_catalog = i18nCatalog("cura")
@ -24,7 +23,8 @@ if TYPE_CHECKING:
from cura.OAuth2.Models import UserProfile, OAuth2Settings
from UM.Preferences import Preferences
MYCLOUD_LOGOFF_URL = "https://mycloud.ultimaker.com/logoff"
MYCLOUD_LOGOFF_URL = "https://account.ultimaker.com/logoff?utm_source=cura&utm_medium=software&utm_campaign=change-account-before-adding-printers"
class AuthorizationService:
"""The authorization service is responsible for handling the login flow, storing user credentials and providing
@ -43,12 +43,13 @@ class AuthorizationService:
self._settings = settings
self._auth_helpers = AuthorizationHelpers(settings)
self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL)
self._auth_data = None # type: Optional[AuthenticationResponse]
self._user_profile = None # type: Optional["UserProfile"]
self._auth_data: Optional[AuthenticationResponse] = None
self._user_profile: Optional["UserProfile"] = None
self._preferences = preferences
self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True)
self._currently_refreshing_token = False # Whether we are currently in the process of refreshing auth. Don't make new requests while busy.
self._unable_to_get_data_message = None # type: Optional[Message]
self._unable_to_get_data_message: Optional[Message] = None
self.onAuthStateChanged.connect(self._authChanged)
@ -62,65 +63,83 @@ class AuthorizationService:
if self._preferences:
self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}")
def getUserProfile(self) -> Optional["UserProfile"]:
"""Get the user profile as obtained from the JWT (JSON Web Token).
def getUserProfile(self, callback: Optional[Callable[[Optional["UserProfile"]], None]] = None) -> None:
"""
Get the user profile as obtained from the JWT (JSON Web Token).
If the JWT is not yet parsed, calling this will take care of that.
:return: UserProfile if a user is logged in, None otherwise.
If the JWT is not yet checked and parsed, calling this will take care of that.
:param callback: Once the user profile is obtained, this function will be called with the given user profile. If
the profile fails to be obtained, this function will be called with None.
See also: :py:method:`cura.OAuth2.AuthorizationService.AuthorizationService._parseJWT`
"""
if self._user_profile:
# We already obtained the profile. No need to make another request for it.
if callback is not None:
callback(self._user_profile)
return
if not self._user_profile:
# If no user profile was stored locally, we try to get it from JWT.
try:
self._user_profile = self._parseJWT()
except requests.exceptions.ConnectionError:
# Unable to get connection, can't login.
Logger.logException("w", "Unable to validate user data with the remote server.")
return None
# If no user profile was stored locally, we try to get it from JWT.
def store_profile(profile: Optional["UserProfile"]) -> None:
if profile is not None:
self._user_profile = profile
if callback is not None:
callback(profile)
elif self._auth_data:
# If there is no user profile from the JWT, we have to log in again.
Logger.warning("The user profile could not be loaded. The user must log in again!")
self.deleteAuthData()
if callback is not None:
callback(None)
else:
if callback is not None:
callback(None)
if not self._user_profile and self._auth_data:
# If there is still no user profile from the JWT, we have to log in again.
Logger.log("w", "The user profile could not be loaded. The user must log in again!")
self.deleteAuthData()
return None
self._parseJWT(callback = store_profile)
return self._user_profile
def _parseJWT(self) -> Optional["UserProfile"]:
"""Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there.
:return: UserProfile if it was able to parse, None otherwise.
def _parseJWT(self, callback: Callable[[Optional["UserProfile"]], None]) -> None:
"""
Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there.
:param callback: A function to call asynchronously once the user profile has been obtained. It will be called
with `None` if it failed to obtain a user profile.
"""
if not self._auth_data or self._auth_data.access_token is None:
# 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)
if user_data:
# If the profile was found, we return it immediately.
return user_data
# The JWT was expired or invalid and we should request a new one.
if self._auth_data.refresh_token is None:
Logger.log("w", "There was no refresh token in the auth data.")
return None
self._auth_data = self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)
if not self._auth_data or self._auth_data.access_token is None:
Logger.log("w", "Unable to use the refresh token to get a new access token.")
# The token could not be refreshed using the refresh token. We should login again.
return None
# Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been deleted
# from the server already. Do not store the auth_data if we could not get new auth_data (eg due to a
# network error), since this would cause an infinite loop trying to get new auth-data
if self._auth_data.success:
self._storeAuthData(self._auth_data)
return self._auth_helpers.parseJWT(self._auth_data.access_token)
Logger.debug("There was no auth data or access token")
callback(None)
return
# When we checked the token we may get a user profile. This callback checks if that is a valid one and tries to refresh the token if it's not.
def check_user_profile(user_profile: Optional["UserProfile"]) -> None:
if user_profile:
# If the profile was found, we call it back immediately.
callback(user_profile)
return
# The JWT was expired or invalid and we should request a new one.
if self._auth_data is None or self._auth_data.refresh_token is None:
Logger.warning("There was no refresh token in the auth data.")
callback(None)
return
def process_auth_data(auth_data: AuthenticationResponse) -> None:
if auth_data.access_token is None:
Logger.warning("Unable to use the refresh token to get a new access token.")
callback(None)
return
# Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been
# deleted from the server already. Do not store the auth_data if we could not get new auth_data (e.g.
# due to a network error), since this would cause an infinite loop trying to get new auth-data.
if auth_data.success:
self._storeAuthData(auth_data)
self._auth_helpers.checkToken(auth_data.access_token, callback, lambda: callback(None))
self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token, process_auth_data)
self._auth_helpers.checkToken(self._auth_data.access_token, check_user_profile, lambda: check_user_profile(None))
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")
@ -142,13 +161,20 @@ class AuthorizationService:
if self._auth_data is None or self._auth_data.refresh_token is None:
Logger.log("w", "Unable to refresh access token, since there is no refresh token.")
return
response = self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)
if response.success:
self._storeAuthData(response)
self.onAuthStateChanged.emit(logged_in = True)
else:
Logger.log("w", "Failed to get a new access token from the server.")
self.onAuthStateChanged.emit(logged_in = False)
def process_auth_data(response: AuthenticationResponse) -> None:
if response.success:
self._storeAuthData(response)
self.onAuthStateChanged.emit(logged_in = True)
else:
Logger.warning("Failed to get a new access token from the server.")
self.onAuthStateChanged.emit(logged_in = False)
if self._currently_refreshing_token:
Logger.debug("Was already busy refreshing token. Do not start a new request.")
return
self._currently_refreshing_token = True
self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token, process_auth_data)
def deleteAuthData(self) -> None:
"""Delete the authentication data that we have stored locally (eg; logout)"""
@ -209,25 +235,27 @@ class AuthorizationService:
link to force the a browser logout from mycloud.ultimaker.com
:return: The authentication URL, properly formatted and encoded
"""
auth_url = "{}?{}".format(self._auth_url, urlencode(query_parameters_dict))
auth_url = f"{self._auth_url}?{urlencode(query_parameters_dict)}"
if force_browser_logout:
# The url after '?next=' should be urlencoded
auth_url = "{}?next={}".format(MYCLOUD_LOGOFF_URL, quote_plus(auth_url))
connecting_char = "&" if "?" in MYCLOUD_LOGOFF_URL else "?"
# The url after 'next=' should be urlencoded
auth_url = f"{MYCLOUD_LOGOFF_URL}{connecting_char}next={quote_plus(auth_url)}"
return auth_url
def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:
"""Callback method for the authentication flow."""
if auth_response.success:
Logger.log("d", "Got callback from Authorization state. The user should now be logged in!")
self._storeAuthData(auth_response)
self.onAuthStateChanged.emit(logged_in = True)
else:
Logger.log("d", "Got callback from Authorization state. Something went wrong: [%s]", auth_response.err_message)
self.onAuthenticationError.emit(logged_in = False, error_message = auth_response.err_message)
self._server.stop() # Stop the web server at all times.
def loadAuthDataFromPreferences(self) -> None:
"""Load authentication data from preferences."""
Logger.log("d", "Attempting to load the auth data from preferences.")
if self._preferences is None:
Logger.log("e", "Unable to load authentication data, since no preference has been set!")
return
@ -235,19 +263,23 @@ class AuthorizationService:
preferences_data = json.loads(self._preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY))
if preferences_data:
self._auth_data = AuthenticationResponse(**preferences_data)
# Also check if we can actually get the user profile information.
user_profile = self.getUserProfile()
if user_profile is not None:
self.onAuthStateChanged.emit(logged_in = True)
else:
if self._unable_to_get_data_message is not None:
self._unable_to_get_data_message.hide()
self._unable_to_get_data_message = Message(i18n_catalog.i18nc("@info",
"Unable to reach the Ultimaker account server."),
title = i18n_catalog.i18nc("@info:title", "Warning"),
message_type = Message.MessageType.ERROR)
self._unable_to_get_data_message.show()
# Also check if we can actually get the user profile information.
def callback(profile: Optional["UserProfile"]) -> None:
if profile is not None:
self.onAuthStateChanged.emit(logged_in = True)
Logger.debug("Auth data was successfully loaded")
else:
if self._unable_to_get_data_message is not None:
self._unable_to_get_data_message.show()
else:
self._unable_to_get_data_message = Message(i18n_catalog.i18nc("@info",
"Unable to reach the Ultimaker account server."),
title = i18n_catalog.i18nc("@info:title", "Log-in failed"),
message_type = Message.MessageType.ERROR)
Logger.warning("Unable to get user profile using auth data from preferences.")
self._unable_to_get_data_message.show()
self.getUserProfile(callback)
except (ValueError, TypeError):
Logger.logException("w", "Could not load auth data from preferences")
@ -260,10 +292,12 @@ class AuthorizationService:
return
self._auth_data = auth_data
self._currently_refreshing_token = False
if auth_data:
self._user_profile = self.getUserProfile()
self.getUserProfile()
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(auth_data.dump()))
else:
Logger.log("d", "Clearing the user profile")
self._user_profile = None
self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)

View file

@ -2,9 +2,10 @@
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Type, TYPE_CHECKING, Optional, List
from io import BlockingIOError
import keyring
from keyring.backend import KeyringBackend
from keyring.errors import NoKeyringError, PasswordSetError, KeyringLocked
from keyring.errors import NoKeyringError, PasswordSetError, KeyringLocked, KeyringError
from UM.Logger import Logger
@ -14,13 +15,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"]
@ -39,10 +45,18 @@ class KeyringAttribute:
self._store_secure = False
Logger.logException("w", "No keyring backend present")
return getattr(instance, self._name)
except KeyringLocked:
except (KeyringLocked, BlockingIOError):
self._store_secure = False
Logger.log("i", "Access to the keyring was denied.")
return getattr(instance, self._name)
except UnicodeDecodeError:
self._store_secure = False
Logger.log("w", "The password retrieved from the keyring cannot be used because it contains characters that cannot be decoded.")
return getattr(instance, self._name)
except KeyringError:
self._store_secure = False
Logger.logException("w", "Unknown keyring error.")
return getattr(instance, self._name)
else:
return getattr(instance, self._name)

View file

@ -72,14 +72,14 @@ class PickingPass(RenderPass):
window_size = self._renderer.getWindowSize()
px = (0.5 + x / 2.0) * window_size[0]
py = (0.5 + y / 2.0) * window_size[1]
px = int((0.5 + x / 2.0) * window_size[0])
py = int((0.5 + y / 2.0) * window_size[1])
if px < 0 or px > (output.width() - 1) or py < 0 or py > (output.height() - 1):
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

@ -1,8 +1,7 @@
# Copyright (c) 2020 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import QTimer
from shapely.errors import TopologicalError # To capture errors if Shapely messes up.
from UM.Application import Application
from UM.Logger import Logger
@ -138,11 +137,7 @@ class PlatformPhysics:
own_convex_hull = node.callDecoration("getConvexHull")
other_convex_hull = other_node.callDecoration("getConvexHull")
if own_convex_hull and other_convex_hull:
try:
overlap = own_convex_hull.translate(move_vector.x, move_vector.z).intersectsPolygon(other_convex_hull)
except TopologicalError as e: # Can happen if the convex hull is degenerate?
Logger.warning("Got a topological error when calculating convex hull intersection: {err}".format(err = str(e)))
overlap = False
overlap = own_convex_hull.translate(move_vector.x, move_vector.z).intersectsPolygon(other_convex_hull)
if overlap: # Moving ensured that overlap was still there. Try anew!
temp_move_vector = move_vector.set(x = move_vector.x + overlap[0] * self._move_factor,
z = move_vector.z + overlap[1] * self._move_factor)

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

@ -42,7 +42,7 @@ class PrintJobOutputModel(QObject):
self._preview_image = None # type: Optional[QImage]
@pyqtProperty("QStringList", notify=compatibleMachineFamiliesChanged)
def compatibleMachineFamilies(self):
def compatibleMachineFamilies(self) -> List[str]:
# Hack; Some versions of cluster will return a family more than once...
return list(set(self._compatible_machine_families))
@ -77,11 +77,11 @@ class PrintJobOutputModel(QObject):
self._configuration = configuration
self.configurationChanged.emit()
@pyqtProperty(str, notify=ownerChanged)
def owner(self):
@pyqtProperty(str, notify = ownerChanged)
def owner(self) -> str:
return self._owner
def updateOwner(self, owner):
def updateOwner(self, owner: str) -> None:
if self._owner != owner:
self._owner = owner
self.ownerChanged.emit()
@ -132,7 +132,7 @@ class PrintJobOutputModel(QObject):
@pyqtProperty(float, notify = timeElapsedChanged)
def progress(self) -> float:
result = float(self.timeElapsed) / max(self.timeTotal, 1.0) # Prevent a division by zero exception.
result = float(self.timeElapsed) / max(self.timeTotal, 1.0) # Prevent a division by zero exception.
return min(result, 1.0) # Never get a progress past 1.0
@pyqtProperty(str, notify=stateChanged)
@ -151,12 +151,12 @@ class PrintJobOutputModel(QObject):
return False
return True
def updateTimeTotal(self, new_time_total):
def updateTimeTotal(self, new_time_total: int) -> None:
if self._time_total != new_time_total:
self._time_total = new_time_total
self.timeTotalChanged.emit()
def updateTimeElapsed(self, new_time_elapsed):
def updateTimeElapsed(self, new_time_elapsed: int) -> None:
if self._time_elapsed != new_time_elapsed:
self._time_elapsed = new_time_elapsed
self.timeElapsedChanged.emit()

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,264 @@
# 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.
)
# Filter out any printer not capable of the 'import_material' capability. Needs FW 7.0.1-RC at the least!
self._printer_metadata = [ printer_data for printer_data in self._printer_metadata if (
UltimakerCloudConstants.META_CAPABILITIES in printer_data and
"import_material" in printer_data[UltimakerCloudConstants.META_CAPABILITIES]
)
]
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

@ -12,6 +12,7 @@ from UM.Scene.SceneNode import SceneNode
from UM.Scene.Selection import Selection
from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator
from UM.Settings.ContainerRegistry import ContainerRegistry # Finding containers by ID.
from cura.Machines.ContainerTree import ContainerTree
from typing import Any, cast, Dict, List, Optional, TYPE_CHECKING, Union
@ -258,11 +259,21 @@ class ExtruderManager(QObject):
if support_roof_enabled:
used_extruder_stack_ids.add(self.extruderIds[self.extruderValueWithDefault(str(global_stack.getProperty("support_roof_extruder_nr", "value")))])
# The platform adhesion extruder. Not used if using none.
if global_stack.getProperty("adhesion_type", "value") != "none" or (
global_stack.getProperty("prime_tower_brim_enable", "value") and
global_stack.getProperty("adhesion_type", "value") != 'raft'):
extruder_str_nr = str(global_stack.getProperty("adhesion_extruder_nr", "value"))
# The platform adhesion extruders.
used_adhesion_extruders = set()
adhesion_type = global_stack.getProperty("adhesion_type", "value")
if adhesion_type == "skirt" and (global_stack.getProperty("skirt_line_count", "value") > 0 or global_stack.getProperty("skirt_brim_minimal_length", "value") > 0):
used_adhesion_extruders.add("skirt_brim_extruder_nr") # There's a skirt.
if (adhesion_type == "brim" or global_stack.getProperty("prime_tower_brim_enable", "value")) and (global_stack.getProperty("brim_line_count", "value") > 0 or global_stack.getProperty("skirt_brim_minimal_length", "value") > 0):
used_adhesion_extruders.add("skirt_brim_extruder_nr") # There's a brim or prime tower brim.
if adhesion_type == "raft":
used_adhesion_extruders.add("raft_base_extruder_nr")
if global_stack.getProperty("raft_interface_layers", "value") > 0:
used_adhesion_extruders.add("raft_interface_extruder_nr")
if global_stack.getProperty("raft_surface_layers", "value") > 0:
used_adhesion_extruders.add("raft_surface_extruder_nr")
for extruder_setting in used_adhesion_extruders:
extruder_str_nr = str(global_stack.getProperty(extruder_setting, "value"))
if extruder_str_nr == "-1":
extruder_str_nr = self._application.getMachineManager().defaultExtruderPosition
if extruder_str_nr in self.extruderIds:
@ -285,8 +296,11 @@ class ExtruderManager(QObject):
global_stack = application.getGlobalContainerStack()
# Starts with the adhesion extruder.
if global_stack.getProperty("adhesion_type", "value") != "none":
return global_stack.getProperty("adhesion_extruder_nr", "value")
adhesion_type = global_stack.getProperty("adhesion_type", "value")
if adhesion_type in {"skirt", "brim"}:
return global_stack.getProperty("skirt_brim_extruder_nr", "value")
if adhesion_type == "raft":
return global_stack.getProperty("raft_base_extruder_nr", "value")
# No adhesion? Well maybe there is still support brim.
if (global_stack.getProperty("support_enable", "value") or global_stack.getProperty("support_structure", "value") == "tree") and global_stack.getProperty("support_brim_enable", "value"):
@ -403,6 +417,35 @@ class ExtruderManager(QObject):
raise IndexError(msg)
extruder_stack_0.definition = extruder_definition
@pyqtSlot("QVariant", result = bool)
def getExtruderHasQualityForMaterial(self, extruder_stack: "ExtruderStack") -> bool:
"""Checks if quality nodes exist for the variant/material combination."""
application = cura.CuraApplication.CuraApplication.getInstance()
global_stack = application.getGlobalContainerStack()
if not global_stack or not extruder_stack:
return False
if not global_stack.getMetaDataEntry("has_materials"):
return True
machine_node = ContainerTree.getInstance().machines[global_stack.definition.getId()]
active_variant_name = extruder_stack.variant.getMetaDataEntry("name")
if active_variant_name not in machine_node.variants:
Logger.log("w", "Could not find the variant %s", active_variant_name)
return True
active_variant_node = machine_node.variants[active_variant_name]
try:
active_material_node = active_variant_node.materials[extruder_stack.material.getMetaDataEntry("base_file")]
except KeyError: # The material in this stack is not a supported material (e.g. wrong filament diameter, as loaded from a project file).
return False
active_material_node_qualities = active_material_node.qualities
if not active_material_node_qualities:
return False
return list(active_material_node_qualities.keys())[0] != "empty_quality"
@pyqtSlot(str, result="QVariant")
def getInstanceExtruderValues(self, key: str) -> List:
"""Get all extruder values for a certain setting.

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)
@ -1534,7 +1535,7 @@ class MachineManager(QObject):
machine_node = ContainerTree.getInstance().machines.get(machine_definition_id)
variant_node = machine_node.variants.get(variant_name)
if variant_node is None:
Logger.error("There is no variant with the name {variant_name}.")
Logger.error(f"There is no variant with the name {variant_name}.")
return
self.setVariant(position, variant_node)

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

@ -3,6 +3,7 @@
import numpy
from PyQt5 import QtCore
from PyQt5.QtCore import QCoreApplication
from PyQt5.QtGui import QImage
from cura.PreviewPass import PreviewPass
@ -46,6 +47,7 @@ class Snapshot:
render_width, render_height = (width, height) if active_camera is None else active_camera.getWindowSize()
render_width = int(render_width)
render_height = int(render_height)
QCoreApplication.processEvents() # This ensures that the opengl context is correctly available
preview_pass = PreviewPass(render_width, render_height)
root = scene.getRoot()

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

@ -17,7 +17,9 @@ class CuraSplashScreen(QSplashScreen):
self._scale = 0.7
self._version_y_offset = 0 # when extra visual elements are in the background image, move version text down
if ApplicationMetadata.IsEnterpriseVersion:
if ApplicationMetadata.IsAlternateVersion:
splash_image = QPixmap(Resources.getPath(Resources.Images, "cura_wip.png"))
elif ApplicationMetadata.IsEnterpriseVersion:
splash_image = QPixmap(Resources.getPath(Resources.Images, "cura_enterprise.png"))
self._version_y_offset = 26
else:
@ -70,7 +72,7 @@ class CuraSplashScreen(QSplashScreen):
font = QFont() # Using system-default font here
font.setPixelSize(18)
painter.setFont(font)
painter.drawText(60, 70 + self._version_y_offset, round(330 * self._scale), round(230 * self._scale), Qt.AlignLeft | Qt.AlignTop, version[0])
painter.drawText(60, 70 + self._version_y_offset, round(330 * self._scale), round(230 * self._scale), Qt.AlignLeft | Qt.AlignTop, version[0] if not ApplicationMetadata.IsAlternateVersion else ApplicationMetadata.CuraBuildType)
if len(version) > 1:
font.setPixelSize(16)
painter.setFont(font)

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

@ -46,7 +46,9 @@ class TextManager(QObject):
line = line.replace("[", "")
line = line.replace("]", "")
open_version = Version(line)
if open_version > Version([14, 99, 99]): # Bit of a hack: We released the 15.x.x versions before 2.x
if open_version < Version([0, 0, 1]): # Something went wrong with parsing, assume non-numerical alternate version that should be on top.
open_version = Version([99, 99, 99])
if Version([14, 99, 99]) < open_version < Version([16, 0, 0]): # Bit of a hack: We released the 15.x.x versions before 2.x
open_version = Version([0, open_version.getMinor(), open_version.getRevision(), open_version.getPostfixVersion()])
open_header = ""
change_logs_dict[open_version] = collections.OrderedDict()
@ -66,7 +68,9 @@ class TextManager(QObject):
text_version = version
if version < Version([1, 0, 0]): # Bit of a hack: We released the 15.x.x versions before 2.x
text_version = Version([15, version.getMinor(), version.getRevision(), version.getPostfixVersion()])
content += "<h1>" + str(text_version) + "</h1><br>"
if version > Version([99, 0, 0]): # Leave it out altogether if it was originally a non-numbered version.
text_version = ""
content += ("<h1>" + str(text_version) + "</h1><br>") if text_version else ""
content += ""
for change in change_logs_dict[version]:
if str(change) != "":

View file

@ -1,7 +1,9 @@
# Copyright (c) 2019 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from collections import deque
import os
from collections import deque
from typing import TYPE_CHECKING, Optional, List, Dict, Any
from PyQt5.QtCore import QUrl, Qt, pyqtSlot, pyqtProperty, pyqtSignal
@ -16,24 +18,23 @@ if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
#
# This is the Qt ListModel that contains all welcome pages data. Each page is a page that can be shown as a step in the
# welcome wizard dialog. Each item in this ListModel represents a page, which contains the following fields:
#
# - id : A unique page_id which can be used in function goToPage(page_id)
# - page_url : The QUrl to the QML file that contains the content of this page
# - next_page_id : (OPTIONAL) The next page ID to go to when this page finished. This is optional. If this is not
# provided, it will go to the page with the current index + 1
# - next_page_button_text: (OPTIONAL) The text to show for the "next" button, by default it's the translated text of
# "Next". Note that each step QML can decide whether to use this text or not, so it's not
# mandatory.
# - should_show_function : (OPTIONAL) An optional function that returns True/False indicating if this page should be
# shown. By default all pages should be shown. If a function returns False, that page will
# be skipped and its next page will be shown.
#
# Note that in any case, a page that has its "should_show_function" == False will ALWAYS be skipped.
#
class WelcomePagesModel(ListModel):
"""
This is the Qt ListModel that contains all welcome pages data. Each page is a page that can be shown as a step in
the welcome wizard dialog. Each item in this ListModel represents a page, which contains the following fields:
- id : A unique page_id which can be used in function goToPage(page_id)
- page_url : The QUrl to the QML file that contains the content of this page
- next_page_id : (OPTIONAL) The next page ID to go to when this page finished. This is optional. If this is
not provided, it will go to the page with the current index + 1
- next_page_button_text : (OPTIONAL) The text to show for the "next" button, by default it's the translated text of
"Next". Note that each step QML can decide whether to use this text or not, so it's not
mandatory.
- should_show_function : (OPTIONAL) An optional function that returns True/False indicating if this page should be
shown. By default all pages should be shown. If a function returns False, that page will
be skipped and its next page will be shown.
Note that in any case, a page that has its "should_show_function" == False will ALWAYS be skipped.
"""
IdRole = Qt.UserRole + 1 # Page ID
PageUrlRole = Qt.UserRole + 2 # URL to the page's QML file
@ -55,11 +56,11 @@ class WelcomePagesModel(ListModel):
self._default_next_button_text = self._catalog.i18nc("@action:button", "Next")
self._pages = [] # type: List[Dict[str, Any]]
self._pages: List[Dict[str, Any]] = []
self._current_page_index = 0
# Store all the previous page indices so it can go back.
self._previous_page_indices_stack = deque() # type: deque
self._previous_page_indices_stack: deque = deque()
# If the welcome flow should be shown. It can show the complete flow or just the changelog depending on the
# specific case. See initialize() for how this variable is set.
@ -72,17 +73,21 @@ class WelcomePagesModel(ListModel):
def currentPageIndex(self) -> int:
return self._current_page_index
# Returns a float number in [0, 1] which indicates the current progress.
@pyqtProperty(float, notify = currentPageIndexChanged)
def currentProgress(self) -> float:
"""
Returns a float number in [0, 1] which indicates the current progress.
"""
if len(self._items) == 0:
return 0
else:
return self._current_page_index / len(self._items)
# Indicates if the current page is the last page.
@pyqtProperty(bool, notify = currentPageIndexChanged)
def isCurrentPageLast(self) -> bool:
"""
Indicates if the current page is the last page.
"""
return self._current_page_index == len(self._items) - 1
def _setCurrentPageIndex(self, page_index: int) -> None:
@ -91,17 +96,22 @@ class WelcomePagesModel(ListModel):
self._current_page_index = page_index
self.currentPageIndexChanged.emit()
# Ends the Welcome-Pages. Put as a separate function for cases like the 'decline' in the User-Agreement.
@pyqtSlot()
def atEnd(self) -> None:
"""
Ends the Welcome-Pages. Put as a separate function for cases like the 'decline' in the User-Agreement.
"""
self.allFinished.emit()
self.resetState()
# Goes to the next page.
# If "from_index" is given, it will look for the next page to show starting from the "from_index" page instead of
# the "self._current_page_index".
@pyqtSlot()
def goToNextPage(self, from_index: Optional[int] = None) -> None:
"""
Goes to the next page.
If "from_index" is given, it will look for the next page to show starting from the "from_index" page instead of
the "self._current_page_index".
"""
# Look for the next page that should be shown
current_index = self._current_page_index if from_index is None else from_index
while True:
@ -137,9 +147,11 @@ class WelcomePagesModel(ListModel):
# Move to the next page
self._setCurrentPageIndex(next_page_index)
# Goes to the previous page. If there's no previous page, do nothing.
@pyqtSlot()
def goToPreviousPage(self) -> None:
"""
Goes to the previous page. If there's no previous page, do nothing.
"""
if len(self._previous_page_indices_stack) == 0:
Logger.log("i", "No previous page, do nothing")
return
@ -148,9 +160,9 @@ class WelcomePagesModel(ListModel):
self._current_page_index = previous_page_index
self.currentPageIndexChanged.emit()
# Sets the current page to the given page ID. If the page ID is not found, do nothing.
@pyqtSlot(str)
def goToPage(self, page_id: str) -> None:
"""Sets the current page to the given page ID. If the page ID is not found, do nothing."""
page_index = self.getPageIndexById(page_id)
if page_index is None:
# FIXME: If we cannot find the next page, we cannot do anything here.
@ -165,18 +177,22 @@ class WelcomePagesModel(ListModel):
# Find the next page to show starting from the "page_index"
self.goToNextPage(from_index = page_index)
# Checks if the page with the given index should be shown by calling the "should_show_function" associated with it.
# If the function is not present, returns True (show page by default).
def _shouldPageBeShown(self, page_index: int) -> bool:
"""
Checks if the page with the given index should be shown by calling the "should_show_function" associated with
it. If the function is not present, returns True (show page by default).
"""
next_page_item = self.getItem(page_index)
should_show_function = next_page_item.get("should_show_function", lambda: True)
return should_show_function()
# Resets the state of the WelcomePagesModel. This functions does the following:
# - Resets current_page_index to 0
# - Clears the previous page indices stack
@pyqtSlot()
def resetState(self) -> None:
"""
Resets the state of the WelcomePagesModel. This functions does the following:
- Resets current_page_index to 0
- Clears the previous page indices stack
"""
self._current_page_index = 0
self._previous_page_indices_stack.clear()
@ -188,8 +204,8 @@ class WelcomePagesModel(ListModel):
def shouldShowWelcomeFlow(self) -> bool:
return self._should_show_welcome_flow
# Gets the page index with the given page ID. If the page ID doesn't exist, returns None.
def getPageIndexById(self, page_id: str) -> Optional[int]:
"""Gets the page index with the given page ID. If the page ID doesn't exist, returns None."""
page_idx = None
for idx, page_item in enumerate(self._items):
if page_item["id"] == page_id:
@ -197,8 +213,9 @@ class WelcomePagesModel(ListModel):
break
return page_idx
# Convenience function to get QUrl path to pages that's located in "resources/qml/WelcomePages".
def _getBuiltinWelcomePagePath(self, page_filename: str) -> "QUrl":
@staticmethod
def _getBuiltinWelcomePagePath(page_filename: str) -> QUrl:
"""Convenience function to get QUrl path to pages that's located in "resources/qml/WelcomePages"."""
from cura.CuraApplication import CuraApplication
return QUrl.fromLocalFile(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles,
os.path.join("WelcomePages", page_filename)))
@ -213,21 +230,22 @@ class WelcomePagesModel(ListModel):
self._initialize()
def _initialize(self, update_should_show_flag: bool = True) -> None:
show_whatsnew_only = False
show_whats_new_only = False
if update_should_show_flag:
has_active_machine = self._application.getMachineManager().activeMachine is not None
has_app_just_upgraded = self._application.hasJustUpdatedFromOldVersion()
# Only show the what's new dialog if there's no machine and we have just upgraded
show_complete_flow = not has_active_machine
show_whatsnew_only = has_active_machine and has_app_just_upgraded
show_whats_new_only = has_active_machine and has_app_just_upgraded
# FIXME: This is a hack. Because of the circular dependency between MachineManager, ExtruderManager, and
# possibly some others, setting the initial active machine is not done when the MachineManager gets initialized.
# So at this point, we don't know if there will be an active machine or not. It could be that the active machine
# files are corrupted so we cannot rely on Preferences either. This makes sure that once the active machine
# gets changed, this model updates the flags, so it can decide whether to show the welcome flow or not.
should_show_welcome_flow = show_complete_flow or show_whatsnew_only
# possibly some others, setting the initial active machine is not done when the MachineManager gets
# initialized. So at this point, we don't know if there will be an active machine or not. It could be that
# the active machine files are corrupted so we cannot rely on Preferences either. This makes sure that once
# the active machine gets changed, this model updates the flags, so it can decide whether to show the
# welcome flow or not.
should_show_welcome_flow = show_complete_flow or show_whats_new_only
if should_show_welcome_flow != self._should_show_welcome_flow:
self._should_show_welcome_flow = should_show_welcome_flow
self.shouldShowWelcomeFlowChanged.emit()
@ -274,23 +292,25 @@ class WelcomePagesModel(ListModel):
]
pages_to_show = all_pages_list
if show_whatsnew_only:
if show_whats_new_only:
pages_to_show = list(filter(lambda x: x["id"] == "whats_new", all_pages_list))
self._pages = pages_to_show
self.setItems(self._pages)
# For convenience, inject the default "next" button text to each item if it's not present.
def setItems(self, items: List[Dict[str, Any]]) -> None:
# For convenience, inject the default "next" button text to each item if it's not present.
for item in items:
if "next_page_button_text" not in item:
item["next_page_button_text"] = self._default_next_button_text
super().setItems(items)
# Indicates if the machine action panel should be shown by checking if there's any first start machine actions
# available.
def shouldShowMachineActions(self) -> bool:
"""
Indicates if the machine action panel should be shown by checking if there's any first start machine actions
available.
"""
global_stack = self._application.getMachineManager().activeMachine
if global_stack is None:
return False
@ -312,6 +332,3 @@ class WelcomePagesModel(ListModel):
def addPage(self) -> None:
pass
__all__ = ["WelcomePagesModel"]

View file

@ -1,27 +1,39 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from .WelcomePagesModel import WelcomePagesModel
import os
from typing import Optional, Dict, List, Tuple
from typing import Optional, Dict, List, Tuple, TYPE_CHECKING
from PyQt5.QtCore import pyqtProperty, pyqtSlot
from UM.Logger import Logger
from UM.Resources import Resources
#
# This Qt ListModel is more or less the same the WelcomePagesModel, except that this model is only for showing the
# "what's new" page. This is also used in the "Help" menu to show the changes log.
#
from cura.UI.WelcomePagesModel import WelcomePagesModel
if TYPE_CHECKING:
from PyQt5.QtCore import QObject
from cura.CuraApplication import CuraApplication
class WhatsNewPagesModel(WelcomePagesModel):
"""
This Qt ListModel is more or less the same the WelcomePagesModel, except that this model is only for showing the
"what's new" page. This is also used in the "Help" menu to show the changes log.
"""
image_formats = [".png", ".jpg", ".jpeg", ".gif", ".svg"]
text_formats = [".txt", ".htm", ".html"]
image_key = "image"
text_key = "text"
def __init__(self, application: "CuraApplication", parent: Optional["QObject"] = None) -> None:
super().__init__(application, parent)
self._subpages: List[Dict[str, Optional[str]]] = []
@staticmethod
def _collectOrdinalFiles(resource_type: int, include: List[str]) -> Tuple[Dict[int, str], int]:
result = {} #type: Dict[int, str]
result = {} # type: Dict[int, str]
highest = -1
try:
folder_path = Resources.getPath(resource_type, "whats_new")
@ -65,7 +77,7 @@ class WhatsNewPagesModel(WelcomePagesModel):
texts, max_text = WhatsNewPagesModel._collectOrdinalFiles(Resources.Texts, WhatsNewPagesModel.text_formats)
highest = max(max_image, max_text)
self._subpages = [] #type: List[Dict[str, Optional[str]]]
self._subpages = []
for n in range(0, highest + 1):
self._subpages.append({
WhatsNewPagesModel.image_key: None if n not in images else images[n],
@ -93,5 +105,3 @@ class WhatsNewPagesModel(WelcomePagesModel):
def getSubpageText(self, page: int) -> str:
result = self._getSubpageItem(page, WhatsNewPagesModel.text_key)
return result if result else "* * *"
__all__ = ["WhatsNewPagesModel"]

View file

@ -0,0 +1,218 @@
# 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.setProperty("syncStatusText", "") # Reset any previous error messages.
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"),
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

@ -13,6 +13,9 @@ DEFAULT_DIGITAL_FACTORY_URL = "https://digitalfactory.ultimaker.com" # type: st
META_UM_LINKED_TO_ACCOUNT = "um_linked_to_account"
"""(bool) Whether a cloud printer is linked to an Ultimaker account"""
META_CAPABILITIES = "capabilities"
"""(list[str]) a list of capabilities this printer supports"""
try:
from cura.CuraVersion import CuraCloudAPIRoot # type: ignore
if CuraCloudAPIRoot == "":

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

View file

@ -1,6 +1,6 @@
#!/usr/bin/env python3
# Copyright (c) 2020 Ultimaker B.V.
# Copyright (c) 2022 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
# Remove the working directory from sys.path.
@ -15,6 +15,9 @@ if "" in sys.path:
import argparse
import faulthandler
import os
if sys.platform != "linux": # Turns out the Linux build _does_ use this, but we're not making an Enterprise release for that system anyway.
os.environ["QT_PLUGIN_PATH"] = "" # Security workaround: Don't need it, and introduces an attack vector, so set to nul.
os.environ["QML2_IMPORT_PATH"] = "" # Security workaround: Don't need it, and introduces an attack vector, so set to nul.
from PyQt5.QtNetwork import QSslConfiguration, QSslSocket
@ -48,6 +51,8 @@ if with_sentry_sdk:
sentry_env = "development" # Master is always a development version.
elif "beta" in ApplicationMetadata.CuraVersion or "BETA" in ApplicationMetadata.CuraVersion:
sentry_env = "beta"
elif "alpha" in ApplicationMetadata.CuraVersion or "ALPHA" in ApplicationMetadata.CuraVersion:
sentry_env = "alpha"
try:
if ApplicationMetadata.CuraVersion.split(".")[2] == "99":
sentry_env = "nightly"

View file

@ -7,9 +7,9 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
PROJECT_DIR="$( cd "${SCRIPT_DIR}/.." && pwd )"
# Make sure that environment variables are set properly
source /opt/rh/devtoolset-8/enable
export PATH="${CURA_BUILD_ENV_PATH}/bin:${PATH}"
export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}"
export LD_LIBRARY_PATH="${CURA_BUILD_ENV_PATH}/lib:${LD_LIBRARY_PATH}"
cd "${PROJECT_DIR}"
@ -50,7 +50,7 @@ do
echo "Found Uranium branch [${URANIUM_BRANCH}]."
break
else
echo "Could not find Uranium banch [${URANIUM_BRANCH}], try next."
echo "Could not find Uranium branch [${URANIUM_BRANCH}], try next."
fi
done
@ -60,7 +60,7 @@ export PYTHONPATH="${PROJECT_DIR}/Uranium:.:${PYTHONPATH}"
mkdir build
cd build
cmake3 \
cmake \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_PREFIX_PATH="${CURA_BUILD_ENV_PATH}" \
-DURANIUM_DIR="${PROJECT_DIR}/Uranium" \

View file

@ -1,3 +1,3 @@
#!/usr/bin/env bash
cd build
ctest3 -j4 --output-on-failure -T Test
ctest -j4 --output-on-failure -T Test

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View file

@ -8,7 +8,7 @@ The build volume draws a cube (for rectangular build plates) that represents the
The build volume also draws a grid underneath the build volume. The grid features 1cm lines which allows the user to roughly estimate how big its print is or the distance between prints. It also features a finer 1mm line pattern within that grid. The grid is drawn as a single quad. This quad is then sent to the graphical card with a specialised shader which draws the grid pattern.
For elliptical build plates, the volume bounds are drawn as two circles, one at the top and one at the bottom of the available height. The build plate grid is drawn as a tesselated circle, but with the same shader.
For elliptical build plates, the volume bounds are drawn as two circles, one at the top and one at the bottom of the available height. The build plate grid is drawn as a tessellated circle, but with the same shader.
Disallowed areas
----

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before After
Before After

View file

@ -49,7 +49,9 @@ _ignored_machine_network_metadata = {
"removal_warning",
"group_name",
"group_size",
"connection_type"
"connection_type",
"capabilities",
"octoprint_api_key",
} # type: Set[str]
@ -377,7 +379,9 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
# - the global stack DOESN'T exist but some/all of the extruder stacks exist
# To simplify this, only check if the global stack exists or not
global_stack_id = self._stripFileToId(global_stack_file)
serialized = archive.open(global_stack_file).read().decode("utf-8")
serialized = GlobalStack._updateSerialized(serialized, global_stack_file)
machine_name = self._getMachineNameFromSerializedStack(serialized)
self._machine_info.metadata_dict = self._getMetaDataDictFromSerializedStack(serialized)

View file

@ -6,7 +6,7 @@ import QtQuick.Controls 2.3
import QtQuick.Layouts 1.3
import QtQuick.Window 2.2
import UM 1.1 as UM
import UM 1.5 as UM
import Cura 1.1 as Cura
UM.Dialog
@ -19,9 +19,7 @@ UM.Dialog
width: minimumWidth
height: Math.max(dialogSummaryItem.height + 2 * buttonsItem.height, minimumHeight) // 2 * button height to also have some extra space around the button relative to the button size
property int comboboxHeight: 15 * screenScaleFactor
property int spacerHeight: 10 * screenScaleFactor
property int doubleSpacerHeight: 20 * screenScaleFactor
property int comboboxHeight: UM.Theme.getSize("default_margin").height
onClosing: manager.notifyClosed()
onVisibleChanged:
@ -46,10 +44,6 @@ UM.Dialog
id: catalog
name: "cura"
}
SystemPalette
{
id: palette
}
ListModel
{
@ -68,45 +62,39 @@ UM.Dialog
{
width: parent.width
height: childrenRect.height
spacing: 2 * screenScaleFactor
Label
spacing: UM.Theme.getSize("default_margin").height
Column
{
id: titleLabel
text: catalog.i18nc("@action:title", "Summary - Cura Project")
font.pointSize: 18
}
Rectangle
{
id: separator
color: palette.text
width: parent.width
height: 1
}
Item // Spacer
{
height: doubleSpacerHeight
width: height
height: cildrenRect.height
UM.Label
{
id: titleLabel
text: catalog.i18nc("@action:title", "Summary - Cura Project")
font: UM.Theme.getFont("large")
}
Rectangle
{
id: separator
color: UM.Theme.getColor("text")
width: parent.width
height: UM.Theme.getSize("default_lining").height
}
}
Row
Item
{
height: childrenRect.height
width: parent.width
Label
{
text: catalog.i18nc("@action:label", "Printer settings")
font.bold: true
width: (parent.width / 3) | 0
}
Item
{
// spacer
height: spacerHeight
width: (parent.width / 3) | 0
}
height: childrenRect.height
UM.TooltipArea
{
id: machineResolveStrategyTooltip
anchors.top: parent.top
anchors.right: parent.right
width: (parent.width / 3) | 0
height: visible ? comboboxHeight : 0
visible: base.visible && machineResolveComboBox.model.count > 1
@ -157,64 +145,65 @@ UM.Dialog
}
}
}
}
Row
{
width: parent.width
height: childrenRect.height
Label
Column
{
text: catalog.i18nc("@action:label", "Type")
width: (parent.width / 3) | 0
}
Label
{
text: manager.machineType
width: (parent.width / 3) | 0
width: parent.width
height: cildrenRect.height
UM.Label
{
id: printer_settings_label
text: catalog.i18nc("@action:label", "Printer settings")
font: UM.Theme.getFont("default_bold")
}
Row
{
width: parent.width
height: childrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Type")
width: (parent.width / 3) | 0
}
UM.Label
{
text: manager.machineType
width: (parent.width / 3) | 0
}
}
Row
{
width: parent.width
height: childrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", manager.isPrinterGroup ? "Printer Group" : "Printer Name")
width: (parent.width / 3) | 0
}
UM.Label
{
text: manager.machineName
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
}
}
Row
Item
{
width: parent.width
height: childrenRect.height
Label
{
text: catalog.i18nc("@action:label", manager.isPrinterGroup ? "Printer Group" : "Printer Name")
width: (parent.width / 3) | 0
}
Label
{
text: manager.machineName
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
Item // Spacer
{
height: doubleSpacerHeight
width: height
}
Row
{
height: childrenRect.height
width: parent.width
Label
{
text: catalog.i18nc("@action:label", "Profile settings")
font.bold: true
width: (parent.width / 3) | 0
}
Item
{
// spacer
height: spacerHeight
width: (parent.width / 3) | 0
}
UM.TooltipArea
{
id: qualityChangesResolveTooltip
anchors.right: parent.right
anchors.top: parent.top
width: (parent.width / 3) | 0
height: visible ? comboboxHeight : 0
visible: manager.qualityChangesConflict
@ -232,96 +221,105 @@ UM.Dialog
}
}
}
Column
{
width: parent.width
height: cildrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Profile settings")
font: UM.Theme.getFont("default_bold")
}
Row
{
width: parent.width
height: childrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Name")
width: (parent.width / 3) | 0
}
UM.Label
{
text: manager.qualityName
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
Row
{
width: parent.width
height: childrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Intent")
width: (parent.width / 3) | 0
}
UM.Label
{
text: manager.intentName
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
Row
{
width: parent.width
height: childrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Not in profile")
visible: manager.numUserSettings != 0
width: (parent.width / 3) | 0
}
UM.Label
{
text: catalog.i18ncp("@action:label", "%1 override", "%1 overrides", manager.numUserSettings).arg(manager.numUserSettings)
visible: manager.numUserSettings != 0
width: (parent.width / 3) | 0
}
}
Row
{
width: parent.width
height: childrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Derivative from")
visible: manager.numSettingsOverridenByQualityChanges != 0
width: (parent.width / 3) | 0
}
UM.Label
{
text: catalog.i18ncp("@action:label", "%1, %2 override", "%1, %2 overrides", manager.numSettingsOverridenByQualityChanges).arg(manager.qualityType).arg(manager.numSettingsOverridenByQualityChanges)
width: (parent.width / 3) | 0
visible: manager.numSettingsOverridenByQualityChanges != 0
wrapMode: Text.WordWrap
}
}
}
}
Row
Item
{
width: parent.width
height: childrenRect.height
Label
{
text: catalog.i18nc("@action:label", "Name")
width: (parent.width / 3) | 0
}
Label
{
text: manager.qualityName
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
Row
{
width: parent.width
height: childrenRect.height
Label
{
text: catalog.i18nc("@action:label", "Intent")
width: (parent.width / 3) | 0
}
Label
{
text: manager.intentName
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
Row
{
width: parent.width
height: manager.numUserSettings != 0 ? childrenRect.height : 0
Label
{
text: catalog.i18nc("@action:label", "Not in profile")
width: (parent.width / 3) | 0
}
Label
{
text: catalog.i18ncp("@action:label", "%1 override", "%1 overrides", manager.numUserSettings).arg(manager.numUserSettings)
width: (parent.width / 3) | 0
}
visible: manager.numUserSettings != 0
}
Row
{
width: parent.width
height: manager.numSettingsOverridenByQualityChanges != 0 ? childrenRect.height : 0
Label
{
text: catalog.i18nc("@action:label", "Derivative from")
width: (parent.width / 3) | 0
}
Label
{
text: catalog.i18ncp("@action:label", "%1, %2 override", "%1, %2 overrides", manager.numSettingsOverridenByQualityChanges).arg(manager.qualityType).arg(manager.numSettingsOverridenByQualityChanges)
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
visible: manager.numSettingsOverridenByQualityChanges != 0
}
Item // Spacer
{
height: doubleSpacerHeight
width: height
}
Row
{
height: childrenRect.height
width: parent.width
Label
{
text: catalog.i18nc("@action:label", "Material settings")
font.bold: true
width: (parent.width / 3) | 0
}
Item
{
// spacer
height: spacerHeight
width: (parent.width / 3) | 0
}
UM.TooltipArea
{
id: materialResolveTooltip
anchors.right: parent.right
anchors.top: parent.top
width: (parent.width / 3) | 0
height: visible ? comboboxHeight : 0
visible: manager.materialConflict
@ -339,76 +337,91 @@ UM.Dialog
}
}
}
Column
{
width: parent.width
height: cildrenRect.height
Row
{
height: childrenRect.height
width: parent.width
spacing: UM.Theme.getSize("narrow_margin").width
UM.Label
{
text: catalog.i18nc("@action:label", "Material settings")
font: UM.Theme.getFont("default_bold")
width: (parent.width / 3) | 0
}
}
Repeater
{
model: manager.materialLabels
delegate: Row
{
width: parent.width
height: childrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Name")
width: (parent.width / 3) | 0
}
UM.Label
{
text: modelData
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
}
}
}
Repeater
Column
{
model: manager.materialLabels
delegate: Row
width: parent.width
height: cildrenRect.height
UM.Label
{
text: catalog.i18nc("@action:label", "Setting visibility")
font: UM.Theme.getFont("default_bold")
}
Row
{
width: parent.width
height: childrenRect.height
Label
UM.Label
{
text: catalog.i18nc("@action:label", "Name")
text: catalog.i18nc("@action:label", "Mode")
width: (parent.width / 3) | 0
}
Label
UM.Label
{
text: modelData
text: manager.activeMode
width: (parent.width / 3) | 0
}
}
Row
{
width: parent.width
height: childrenRect.height
visible: manager.hasVisibleSettingsField
UM.Label
{
text: catalog.i18nc("@action:label", "Visible settings:")
width: (parent.width / 3) | 0
}
UM.Label
{
text: catalog.i18nc("@action:label", "%1 out of %2" ).arg(manager.numVisibleSettings).arg(manager.totalNumberOfSettings)
width: (parent.width / 3) | 0
wrapMode: Text.WordWrap
}
}
}
Item // Spacer
{
height: doubleSpacerHeight
width: height
}
Label
{
text: catalog.i18nc("@action:label", "Setting visibility")
font.bold: true
}
Row
{
width: parent.width
height: childrenRect.height
Label
{
text: catalog.i18nc("@action:label", "Mode")
width: (parent.width / 3) | 0
}
Label
{
text: manager.activeMode
width: (parent.width / 3) | 0
}
}
Row
{
width: parent.width
height: childrenRect.height
visible: manager.hasVisibleSettingsField
Label
{
text: catalog.i18nc("@action:label", "Visible settings:")
width: (parent.width / 3) | 0
}
Label
{
text: catalog.i18nc("@action:label", "%1 out of %2" ).arg(manager.numVisibleSettings).arg(manager.totalNumberOfSettings)
width: (parent.width / 3) | 0
}
}
Item // Spacer
{
height: spacerHeight
width: height
}
Row
{
width: parent.width
@ -418,12 +431,10 @@ UM.Dialog
{
width: warningLabel.height
height: width
source: UM.Theme.getIcon("Information")
color: palette.text
color: UM.Theme.getColor("text")
}
Label
UM.Label
{
id: warningLabel
text: catalog.i18nc("@action:warning", "Loading a project will clear all models on the build plate.")
@ -432,44 +443,22 @@ UM.Dialog
}
}
}
Item
{
id: buttonsItem
width: parent.width
height: childrenRect.height
anchors.bottom: parent.bottom
anchors.right: parent.right
Button
buttonSpacing: UM.Theme.getSize("default_margin").width
rightButtons: [
Cura.TertiaryButton
{
id: cancel_button
text: catalog.i18nc("@action:button","Cancel");
onClicked: { manager.onCancelButtonClicked() }
enabled: true
anchors.bottom: parent.bottom
anchors.right: ok_button.left
anchors.rightMargin: 2 * screenScaleFactor
}
Button
text: catalog.i18nc("@action:button", "Cancel")
onClicked: reject()
},
Cura.PrimaryButton
{
id: ok_button
anchors.right: parent.right
anchors.bottom: parent.bottom
text: catalog.i18nc("@action:button","Open");
onClicked: { manager.closeBackend(); manager.onOkButtonClicked() }
text: catalog.i18nc("@action:button", "Open")
onClicked: accept()
}
}
]
function accept() {
manager.closeBackend();
manager.onOkButtonClicked();
base.visible = false;
base.accept();
}
function reject() {
manager.onCancelButtonClicked();
base.visible = false;
base.rejected();
}
onRejected: manager.onCancelButtonClicked()
onAccepted: manager.onOkButtonClicked()
}

View file

@ -32,6 +32,12 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
Logger.error("3MF Writer class is unavailable. Can't write workspace.")
return False
global_stack = machine_manager.activeMachine
if global_stack is None:
self.setInformation(catalog.i18nc("@error", "There is no workspace yet to write. Please add a printer first."))
Logger.error("Tried to write a 3MF workspace before there was a global stack.")
return False
# Indicate that the 3mf mesh writer should not close the archive just yet (we still need to add stuff to it).
mesh_writer.setStoreArchive(True)
mesh_writer.write(stream, nodes, mode)
@ -40,7 +46,6 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
if archive is None: # This happens if there was no mesh data to write.
archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED)
global_stack = machine_manager.activeMachine
try:
# Add global container stack data to the archive.
@ -149,7 +154,8 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
"group_name",
"group_size",
"connection_type",
"octoprint_api_key"
"capabilities",
"octoprint_api_key",
}
serialized_data = container.serialize(ignored_metadata_keys = ignore_keys)

View file

@ -10,6 +10,10 @@ from UM.Application import Application
from UM.Scene.SceneNode import SceneNode
from cura.CuraApplication import CuraApplication
from cura.Utils.Threading import call_on_qt_thread
from cura.Snapshot import Snapshot
from PyQt5.QtCore import QBuffer
import Savitar
@ -149,6 +153,22 @@ class ThreeMFWriter(MeshWriter):
relations_element = ET.Element("Relationships", xmlns = self._namespaces["relationships"])
model_relation_element = ET.SubElement(relations_element, "Relationship", Target = "/3D/3dmodel.model", Id = "rel0", Type = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel")
# Attempt to add a thumbnail
snapshot = self._createSnapshot()
if snapshot:
thumbnail_buffer = QBuffer()
thumbnail_buffer.open(QBuffer.ReadWrite)
snapshot.save(thumbnail_buffer, "PNG")
thumbnail_file = zipfile.ZipInfo("Metadata/thumbnail.png")
# Don't try to compress snapshot file, because the PNG is pretty much as compact as it will get
archive.writestr(thumbnail_file, thumbnail_buffer.data())
# Add PNG to content types file
thumbnail_type = ET.SubElement(content_types, "Default", Extension = "png", ContentType = "image/png")
# Add thumbnail relation to _rels/.rels file
thumbnail_relation_element = ET.SubElement(relations_element, "Relationship", Target = "/Metadata/thumbnail.png", Id = "rel1", Type = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail")
savitar_scene = Savitar.Scene()
metadata_to_store = CuraApplication.getInstance().getController().getScene().getMetaData()
@ -212,3 +232,17 @@ class ThreeMFWriter(MeshWriter):
self._archive = archive
return True
@call_on_qt_thread # must be called from the main thread because of OpenGL
def _createSnapshot(self):
Logger.log("d", "Creating thumbnail image...")
if not CuraApplication.getInstance().isVisible:
Logger.log("w", "Can't create snapshot when renderer not initialized.")
return None
try:
snapshot = Snapshot.snapshot(width = 300, height = 300)
except:
Logger.logException("w", "Failed to create snapshot image")
return None
return snapshot

View file

@ -1,39 +1,34 @@
// Copyright (c) 2018 Ultimaker B.V.
// Copyright (c) 2022 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7
import QtQuick.Controls 2.2
import QtQuick.Layouts 1.3
import UM 1.1 as UM
import UM 1.5 as UM
ScrollView
ListView
{
property alias model: backupList.model
width: parent.width
clip: true
ListView
ScrollBar.vertical: UM.ScrollBar {}
delegate: Item
{
id: backupList
width: parent.width
delegate: Item
// Add a margin, otherwise the scrollbar is on top of the right most component
width: parent.width - UM.Theme.getSize("scrollbar").width
height: childrenRect.height
BackupListItem
{
// Add a margin, otherwise the scrollbar is on top of the right most component
width: parent.width - UM.Theme.getSize("default_margin").width
height: childrenRect.height
id: backupListItem
width: parent.width
}
BackupListItem
{
id: backupListItem
width: parent.width
}
Rectangle
{
id: divider
color: UM.Theme.getColor("lining")
height: UM.Theme.getSize("default_lining").height
}
Rectangle
{
id: divider
color: UM.Theme.getColor("lining")
height: UM.Theme.getSize("default_lining").height
}
}
}

View file

@ -5,7 +5,7 @@ import QtQuick 2.7
import QtQuick.Controls 2.1
import QtQuick.Layouts 1.3
import UM 1.3 as UM
import UM 1.5 as UM
import Cura 1.0 as Cura
import "../components"
@ -35,7 +35,7 @@ RowLayout
busy: CuraDrive.isCreatingBackup
}
Cura.CheckBoxWithTooltip
UM.CheckBox
{
id: autoBackupEnabled
checked: CuraDrive.autoBackupEnabled

View file

@ -1,12 +1,11 @@
// Copyright (c) 2018 Ultimaker B.V.
// Copyright (c) 2022 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7
import QtQuick.Controls 2.1
import QtQuick.Layouts 1.3
import QtQuick.Dialogs 1.1
import UM 1.1 as UM
import UM 1.5 as UM
import Cura 1.0 as Cura
Item
@ -42,28 +41,22 @@ Item
onClicked: backupListItem.showDetails = !backupListItem.showDetails
}
Label
UM.Label
{
text: new Date(modelData.generated_time).toLocaleString(UM.Preferences.getValue("general/language"))
color: UM.Theme.getColor("text")
elide: Text.ElideRight
Layout.minimumWidth: 100 * screenScaleFactor
Layout.maximumWidth: 500 * screenScaleFactor
Layout.fillWidth: true
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
}
Label
UM.Label
{
text: modelData.metadata.description
color: UM.Theme.getColor("text")
elide: Text.ElideRight
Layout.minimumWidth: 100 * screenScaleFactor
Layout.maximumWidth: 500 * screenScaleFactor
Layout.fillWidth: true
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
}
Cura.SecondaryButton
@ -94,21 +87,21 @@ Item
anchors.top: dataRow.bottom
}
MessageDialog
Cura.MessageDialog
{
id: confirmDeleteDialog
title: catalog.i18nc("@dialog:title", "Delete Backup")
text: catalog.i18nc("@dialog:info", "Are you sure you want to delete this backup? This cannot be undone.")
standardButtons: StandardButton.Yes | StandardButton.No
onYes: CuraDrive.deleteBackup(modelData.backup_id)
standardButtons: Dialog.Yes | Dialog.No
onAccepted: CuraDrive.deleteBackup(modelData.backup_id)
}
MessageDialog
Cura.MessageDialog
{
id: confirmRestoreDialog
title: catalog.i18nc("@dialog:title", "Restore Backup")
text: catalog.i18nc("@dialog:info", "You will need to restart Cura before your backup is restored. Do you want to close Cura now?")
standardButtons: StandardButton.Yes | StandardButton.No
onYes: CuraDrive.restoreBackup(modelData.backup_id)
standardButtons: Dialog.Yes | Dialog.No
onAccepted: CuraDrive.restoreBackup(modelData.backup_id)
}
}

View file

@ -5,7 +5,7 @@ import QtQuick 2.7
import QtQuick.Controls 2.1
import QtQuick.Layouts 1.3
import UM 1.3 as UM
import UM 1.5 as UM
RowLayout
{
@ -26,27 +26,21 @@ RowLayout
color: UM.Theme.getColor("text")
}
Label
UM.Label
{
id: detailName
color: UM.Theme.getColor("text")
elide: Text.ElideRight
Layout.minimumWidth: 50 * screenScaleFactor
Layout.maximumWidth: 100 * screenScaleFactor
Layout.fillWidth: true
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
}
Label
UM.Label
{
id: detailValue
color: UM.Theme.getColor("text")
elide: Text.ElideRight
Layout.minimumWidth: 50 * screenScaleFactor
Layout.maximumWidth: 100 * screenScaleFactor
Layout.fillWidth: true
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View file

@ -5,7 +5,7 @@ import QtQuick 2.7
import QtQuick.Controls 2.1
import QtQuick.Window 2.2
import UM 1.3 as UM
import UM 1.5 as UM
import Cura 1.1 as Cura
import "../components"
@ -23,23 +23,19 @@ Column
{
id: profileImage
fillMode: Image.PreserveAspectFit
source: "../images/icon.png"
source: "../images/backup.svg"
anchors.horizontalCenter: parent.horizontalCenter
width: Math.round(parent.width / 4)
}
Label
UM.Label
{
id: welcomeTextLabel
text: catalog.i18nc("@description", "Backup and synchronize your Cura settings.")
width: Math.round(parent.width / 2)
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("text")
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter
wrapMode: Label.WordWrap
renderType: Text.NativeRendering
}
Cura.PrimaryButton

View file

@ -159,7 +159,8 @@ class CuraEngineBackend(QObject, Backend):
self._slicing_error_message = Message(
text = catalog.i18nc("@message", "Slicing failed with an unexpected error. Please consider reporting a bug on our issue tracker."),
title = catalog.i18nc("@message:title", "Slicing failed")
title = catalog.i18nc("@message:title", "Slicing failed"),
message_type = Message.MessageType.ERROR
)
self._slicing_error_message.addAction(
action_id = "report_bug",
@ -427,6 +428,7 @@ class CuraEngineBackend(QObject, Backend):
"Unable to slice with the current settings. The following settings have errors: {0}").format(", ".join(error_labels)),
title = catalog.i18nc("@info:title", "Unable to slice"),
message_type = Message.MessageType.WARNING)
Logger.warning(f"Unable to slice with the current settings. The following settings have errors: {', '.join(error_labels)}")
self._error_message.show()
self.setState(BackendState.Error)
self.backendError.emit(job)
@ -453,6 +455,7 @@ class CuraEngineBackend(QObject, Backend):
"Unable to slice due to some per-model settings. The following settings have errors on one or more models: {error_labels}").format(error_labels = ", ".join(errors.values())),
title = catalog.i18nc("@info:title", "Unable to slice"),
message_type = Message.MessageType.WARNING)
Logger.warning(f"Unable to slice due to per-object settings. The following settings have errors on one or more models: {', '.join(errors.values())}")
self._error_message.show()
self.setState(BackendState.Error)
self.backendError.emit(job)
@ -467,6 +470,7 @@ class CuraEngineBackend(QObject, Backend):
self._error_message.show()
self.setState(BackendState.Error)
self.backendError.emit(job)
return
else:
self.setState(BackendState.NotStarted)
@ -645,7 +649,7 @@ class CuraEngineBackend(QObject, Backend):
for node in DepthFirstIterator(self._scene.getRoot()):
if node.callDecoration("getLayerData"):
if not build_plate_numbers or node.callDecoration("getBuildPlateNumber") in build_plate_numbers:
# We can asume that all nodes have a parent as we're looping through the scene (and filter out root)
# We can assume that all nodes have a parent as we're looping through the scene (and filter out root)
cast(SceneNode, node.getParent()).removeChild(node)
def markSliceAll(self) -> None:

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 numpy
@ -123,6 +123,9 @@ class StartSliceJob(Job):
Job.yieldThread()
for changed_setting_key in changed_setting_keys:
if not stack.getProperty(changed_setting_key, "enabled"):
continue
validation_state = stack.getProperty(changed_setting_key, "validationState")
if validation_state is None:
@ -195,13 +198,20 @@ class StartSliceJob(Job):
# Remove old layer data.
for node in DepthFirstIterator(self._scene.getRoot()):
if node.callDecoration("getLayerData") and node.callDecoration("getBuildPlateNumber") == self._build_plate_number:
# Singe we walk through all nodes in the scene, they always have a parent.
# Since we walk through all nodes in the scene, they always have a parent.
cast(SceneNode, node.getParent()).removeChild(node)
break
# Get the objects in their groups to print.
object_groups = []
if stack.getProperty("print_sequence", "value") == "one_at_a_time":
modifier_mesh_nodes = []
for node in DepthFirstIterator(self._scene.getRoot()):
build_plate_number = node.callDecoration("getBuildPlateNumber")
if node.callDecoration("isNonPrintingMesh") and build_plate_number == self._build_plate_number:
modifier_mesh_nodes.append(node)
for node in OneAtATimeIterator(self._scene.getRoot()):
temp_list = []
@ -218,7 +228,7 @@ class StartSliceJob(Job):
temp_list.append(child_node)
if temp_list:
object_groups.append(temp_list)
object_groups.append(temp_list + modifier_mesh_nodes)
Job.yieldThread()
if len(object_groups) == 0:
Logger.log("w", "No objects suitable for one at a time found, or no correct order found")
@ -353,10 +363,19 @@ class StartSliceJob(Job):
result[key] = stack.getProperty(key, "value")
Job.yieldThread()
result["print_bed_temperature"] = result["material_bed_temperature"] # Renamed settings.
# Material identification in addition to non-human-readable GUID
result["material_id"] = stack.material.getMetaDataEntry("base_file", "")
result["material_type"] = stack.material.getMetaDataEntry("material", "")
result["material_name"] = stack.material.getMetaDataEntry("name", "")
result["material_brand"] = stack.material.getMetaDataEntry("brand", "")
# Renamed settings.
result["print_bed_temperature"] = result["material_bed_temperature"]
result["print_temperature"] = result["material_print_temperature"]
result["travel_speed"] = result["speed_travel"]
result["time"] = time.strftime("%H:%M:%S") #Some extra settings.
#Some extra settings.
result["time"] = time.strftime("%H:%M:%S")
result["date"] = time.strftime("%d-%m-%Y")
result["day"] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][int(time.strftime("%w"))]
result["initial_extruder_nr"] = CuraApplication.getInstance().getExtruderManager().getInitialExtruderNr()
@ -455,9 +474,9 @@ class StartSliceJob(Job):
bed_temperature_settings = ["material_bed_temperature", "material_bed_temperature_layer_0"]
pattern = r"\{(%s)(,\s?\w+)?\}" % "|".join(bed_temperature_settings) # match {setting} as well as {setting, extruder_nr}
settings["material_bed_temp_prepend"] = re.search(pattern, start_gcode) == None
print_temperature_settings = ["material_print_temperature", "material_print_temperature_layer_0", "default_material_print_temperature", "material_initial_print_temperature", "material_final_print_temperature", "material_standby_temperature"]
print_temperature_settings = ["material_print_temperature", "material_print_temperature_layer_0", "default_material_print_temperature", "material_initial_print_temperature", "material_final_print_temperature", "material_standby_temperature", "print_temperature"]
pattern = r"\{(%s)(,\s?\w+)?\}" % "|".join(print_temperature_settings) # match {setting} as well as {setting, extruder_nr}
settings["material_print_temp_prepend"] = re.search(pattern, start_gcode) == None
settings["material_print_temp_prepend"] = re.search(pattern, start_gcode) is None
# Replace the setting tokens in start and end g-code.
# Use values from the first used extruder by default so we get the expected temperatures

View file

@ -2,7 +2,7 @@
"name": "Ultimaker Digital Library",
"author": "Ultimaker B.V.",
"description": "Connects to the Digital Library, allowing Cura to open files from and save files to the Digital Library.",
"version": "1.0.0",
"version": "1.1.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -1,10 +1,9 @@
// Copyright (C) 2021 Ultimaker B.V.
//Copyright (C) 2022 Ultimaker B.V.
//Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura

View file

@ -1,10 +1,9 @@
// Copyright (C) 2021 Ultimaker B.V.
//Copyright (C) 2022 Ultimaker B.V.
//Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura

View file

@ -1,10 +1,9 @@
// Copyright (C) 2021 Ultimaker B.V.
//Copyright (C) 2022 Ultimaker B.V.
//Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura

View file

@ -1,10 +1,10 @@
// Copyright (C) 2021 Ultimaker B.V.
//Copyright (C) 2022 Ultimaker B.V.
//Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10
import Qt.labs.qmlmodels 1.0
import QtQuick 2.15
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
@ -57,52 +57,32 @@ Item
border.width: UM.Theme.getSize("default_lining").width
border.color: UM.Theme.getColor("lining")
Cura.TableView
//We can't use Cura's TableView here, since in Cura >= 5.0 this uses QtQuick.TableView, while in Cura < 5.0 this uses QtControls1.TableView.
//So we have to define our own. Once support for 4.13 and earlier is dropped, we can switch to Cura.TableView.
Table
{
id: filesTableView
anchors.fill: parent
model: manager.digitalFactoryFileModel
visible: model.count != 0 && manager.retrievingFileStatus != DF.RetrievalStatus.InProgress
selectionMode: OldControls.SelectionMode.SingleSelection
onDoubleClicked:
anchors.margins: parent.border.width
columnHeaders: ["Name", "Uploaded by", "Uploaded at"]
model: TableModel
{
TableModelColumn { display: "fileName" }
TableModelColumn { display: "username" }
TableModelColumn { display: "uploadedAt" }
rows: manager.digitalFactoryFileModel.items
}
onCurrentRowChanged:
{
manager.setSelectedFileIndices([currentRow]);
}
onDoubleClicked: function(row)
{
manager.setSelectedFileIndices([row]);
openFilesButton.clicked();
}
OldControls.TableViewColumn
{
id: fileNameColumn
role: "fileName"
title: "Name"
width: Math.round(filesTableView.width / 3)
}
OldControls.TableViewColumn
{
id: usernameColumn
role: "username"
title: "Uploaded by"
width: Math.round(filesTableView.width / 3)
}
OldControls.TableViewColumn
{
role: "uploadedAt"
title: "Uploaded at"
}
Connections
{
target: filesTableView.selection
function onSelectionChanged()
{
let newSelection = [];
filesTableView.selection.forEach(function(rowIndex) { newSelection.push(rowIndex); });
manager.setSelectedFileIndices(newSelection);
}
}
}
Label
@ -161,7 +141,6 @@ Item
{
// Make sure no files are selected when the file model changes
filesTableView.currentRow = -1
filesTableView.selection.clear()
}
}
}
@ -187,7 +166,7 @@ Item
anchors.bottom: parent.bottom
anchors.right: parent.right
text: "Open"
enabled: filesTableView.selection.count > 0
enabled: filesTableView.currentRow >= 0
onClicked:
{
manager.openSelectedFiles()

View file

@ -44,7 +44,7 @@ Cura.RoundedRectangle
{
id: projectImage
anchors.verticalCenter: parent.verticalCenter
width: UM.Theme.getSize("toolbox_thumbnail_small").width
width: UM.Theme.getSize("card_icon").width
height: Math.round(width * 3/4)
sourceSize.width: width
sourceSize.height: height

View file

@ -1,12 +1,12 @@
// Copyright (C) 2021 Ultimaker B.V.
//Copyright (C) 2022 Ultimaker B.V.
//Cura is released under the terms of the LGPLv3 or higher.
import Qt.labs.qmlmodels 1.0
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import UM 1.5 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
@ -86,35 +86,22 @@ Item
border.width: UM.Theme.getSize("default_lining").width
border.color: UM.Theme.getColor("lining")
Cura.TableView
//We can't use Cura's TableView here, since in Cura >= 5.0 this uses QtQuick.TableView, while in Cura < 5.0 this uses QtControls1.TableView.
//So we have to define our own. Once support for 4.13 and earlier is dropped, we can switch to Cura.TableView.
Table
{
id: filesTableView
anchors.fill: parent
model: manager.digitalFactoryFileModel
visible: model.count != 0 && manager.retrievingFileStatus != DF.RetrievalStatus.InProgress
selectionMode: OldControls.SelectionMode.NoSelection
anchors.margins: parent.border.width
OldControls.TableViewColumn
allowSelection: false
columnHeaders: ["Name", "Uploaded by", "Uploaded at"]
model: TableModel
{
id: fileNameColumn
role: "fileName"
title: "@tableViewColumn:title", "Name"
width: Math.round(filesTableView.width / 3)
}
OldControls.TableViewColumn
{
id: usernameColumn
role: "username"
title: "Uploaded by"
width: Math.round(filesTableView.width / 3)
}
OldControls.TableViewColumn
{
role: "uploadedAt"
title: "Uploaded at"
TableModelColumn { display: "fileName" }
TableModelColumn { display: "username" }
TableModelColumn { display: "uploadedAt" }
rows: manager.digitalFactoryFileModel.items
}
}
@ -173,8 +160,7 @@ Item
function onItemsChanged()
{
// Make sure no files are selected when the file model changes
filesTableView.currentRow = -1
filesTableView.selection.clear()
filesTableView.currentRow = -1;
}
}
}
@ -200,7 +186,7 @@ Item
anchors.bottom: parent.bottom
anchors.right: parent.right
text: "Save"
enabled: (asProjectCheckbox.checked || asSlicedCheckbox.checked) && dfFilenameTextfield.text.length >= 1
enabled: (asProjectCheckbox.checked || asSlicedCheckbox.checked) && dfFilenameTextfield.text.length >= 1 && dfFilenameTextfield.state !== 'invalid'
onClicked:
{
@ -228,7 +214,7 @@ Item
width: childrenRect.width
spacing: UM.Theme.getSize("default_margin").width
Cura.CheckBox
UM.CheckBox
{
id: asProjectCheckbox
height: UM.Theme.getSize("checkbox").height
@ -238,7 +224,7 @@ Item
font: UM.Theme.getFont("medium")
}
Cura.CheckBox
UM.CheckBox
{
id: asSlicedCheckbox
height: UM.Theme.getSize("checkbox").height

View file

@ -1,15 +1,13 @@
// Copyright (C) 2021 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
//Copyright (C) 2022 Ultimaker B.V.
//Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import QtQuick.Layouts 1.1
import UM 1.2 as UM
import Cura 1.6 as Cura
import Cura 1.7 as Cura
import DigitalFactory 1.0 as DF
@ -44,16 +42,13 @@ Item
height: childrenRect.height
spacing: UM.Theme.getSize("default_margin").width
Cura.TextField
Cura.SearchBar
{
id: searchBar
Layout.fillWidth: true
implicitHeight: createNewProjectButton.height
focus: true
onTextEdited: manager.projectFilter = text //Update the search filter when editing this text field.
leftIcon: UM.Theme.getIcon("Magnifier")
placeholderText: "Search"
}
Cura.SecondaryButton
@ -76,12 +71,11 @@ Item
id: upgradePlanButton
text: "Upgrade plan"
iconSource: UM.Theme.getIcon("LinkExternal")
iconSource: UM.Theme.getIcon("external_link")
visible: createNewProjectButtonVisible && !manager.userAccountCanCreateNewLibraryProject && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
tooltip: "You have reached the maximum number of projects allowed by your subscription. Please upgrade to the Professional subscription to create more projects."
tooltipWidth: parent.width * 0.5
tooltip: "Maximum number of projects reached. Please upgrade your subscription to create more projects."
onClicked: Qt.openUrlExternally("https://ultimaker.com/software/ultimaker-essentials/sign-up-cura?utm_source=cura&utm_medium=software&utm_campaign=lib-max")
onClicked: Qt.openUrlExternally("https://ultimaker.com/software/enterprise-software?utm_source=cura&utm_medium=software&utm_campaign=MaxProjLink")
}
}
@ -124,7 +118,7 @@ Item
id: visitDigitalLibraryButton
anchors.horizontalCenter: parent.horizontalCenter
text: "Visit Digital Library"
onClicked: Qt.openUrlExternally(CuraApplication.ultimakerDigitalFactoryUrl + "/app/library")
onClicked: Qt.openUrlExternally(CuraApplication.ultimakerDigitalFactoryUrl + "/app/library?utm_source=cura&utm_medium=software&utm_campaign=empty-library")
visible: searchBar.text === "" //Show the link to Digital Library when there are no projects in the user's Library.
}
}
@ -206,7 +200,7 @@ Item
LoadMoreProjectsCard
{
id: loadMoreProjectsCard
height: UM.Theme.getSize("toolbox_thumbnail_small").height
height: UM.Theme.getSize("card_icon").height
width: parent.width
visible: manager.digitalFactoryProjectModel.count > 0
hasMoreProjectsToLoad: manager.hasMoreProjectsToLoad
@ -228,4 +222,4 @@ Item
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
}
}
}

View file

@ -0,0 +1,203 @@
//Copyright (C) 2022 Ultimaker B.V.
//Cura is released under the terms of the LGPLv3 or higher.
import Qt.labs.qmlmodels 1.0
import QtQuick 2.15
import QtQuick.Controls 2.15
import UM 1.2 as UM
/*
* A re-sizeable table of data.
*
* This table combines a list of headers with a TableView to show certain roles in a table.
* The columns of the table can be resized.
* When the table becomes too big, you can scroll through the table. When a column becomes too small, the contents of
* the table are elided.
* The table gets Cura's themeing.
*/
Item
{
id: tableBase
required property var columnHeaders //The text to show in the headers of each column.
property alias model: tableView.model //A TableModel to display in this table. To use a ListModel for the rows, use "rows: listModel.items"
property int currentRow: -1 //The selected row index.
property var onDoubleClicked: function(row) {} //Something to execute when double clicked. Accepts one argument: The index of the row that was clicked on.
property bool allowSelection: true //Whether to allow the user to select items.
Row
{
id: headerBar
Repeater
{
id: headerRepeater
model: columnHeaders
Rectangle
{
//minimumWidth: Math.max(1, Math.round(tableBase.width / headerRepeater.count))
width: 300
height: UM.Theme.getSize("section").height
color: UM.Theme.getColor("secondary")
Label
{
id: contentText
anchors.left: parent.left
anchors.leftMargin: UM.Theme.getSize("narrow_margin").width
anchors.right: parent.right
anchors.rightMargin: UM.Theme.getSize("narrow_margin").width
text: modelData
font: UM.Theme.getFont("medium_bold")
color: UM.Theme.getColor("text")
elide: Text.ElideRight
}
Rectangle //Resize handle.
{
anchors
{
right: parent.right
top: parent.top
bottom: parent.bottom
}
width: UM.Theme.getSize("thick_lining").width
color: UM.Theme.getColor("thick_lining")
MouseArea
{
anchors.fill: parent
cursorShape: Qt.SizeHorCursor
drag
{
target: parent
axis: Drag.XAxis
}
onMouseXChanged:
{
if(drag.active)
{
let new_width = parent.parent.width + mouseX;
let sum_widths = mouseX;
for(let i = 0; i < headerBar.children.length; ++i)
{
sum_widths += headerBar.children[i].width;
}
if(sum_widths > tableBase.width)
{
new_width -= sum_widths - tableBase.width; //Limit the total width to not exceed the view.
}
let width_fraction = new_width / tableBase.width; //Scale with the same fraction along with the total width, if the table is resized.
parent.parent.width = Qt.binding(function() { return Math.max(10, Math.round(tableBase.width * width_fraction)) });
}
}
}
}
onWidthChanged:
{
tableView.forceLayout(); //Rescale table cells underneath as well.
}
}
}
}
TableView
{
id: tableView
anchors
{
top: headerBar.bottom
left: parent.left
right: parent.right
bottom: parent.bottom
}
flickableDirection: Flickable.AutoFlickIfNeeded
clip: true
ScrollBar.vertical: ScrollBar
{
// Vertical ScrollBar, styled similarly to the scrollBar in the settings panel
id: verticalScrollBar
visible: tableView.contentHeight > tableView.height
background: Rectangle
{
implicitWidth: UM.Theme.getSize("scrollbar").width
radius: Math.round(implicitWidth / 2)
color: UM.Theme.getColor("scrollbar_background")
}
contentItem: Rectangle
{
id: scrollViewHandle
implicitWidth: UM.Theme.getSize("scrollbar").width
radius: Math.round(implicitWidth / 2)
color: verticalScrollBar.pressed ? UM.Theme.getColor("scrollbar_handle_down") : verticalScrollBar.hovered ? UM.Theme.getColor("scrollbar_handle_hover") : UM.Theme.getColor("scrollbar_handle")
Behavior on color { ColorAnimation { duration: 50; } }
}
}
columnWidthProvider: function(column)
{
return headerBar.children[column].width; //Cells get the same width as their column header.
}
delegate: Rectangle
{
implicitHeight: Math.max(1, cellContent.height)
color: UM.Theme.getColor((tableBase.currentRow == row) ? "primary" : ((row % 2 == 0) ? "main_background" : "viewport_background"))
Label
{
id: cellContent
width: parent.width
text: display
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("text")
}
TextMetrics
{
id: cellTextMetrics
text: cellContent.text
font: cellContent.font
elide: cellContent.elide
elideWidth: cellContent.width
}
UM.TooltipArea
{
anchors.fill: parent
acceptedButtons: Qt.LeftButton
text: (cellTextMetrics.elidedText == cellContent.text) ? "" : cellContent.text //Show full text in tooltip if it was elided.
onClicked:
{
if(tableBase.allowSelection)
{
tableBase.currentRow = row; //Select this row.
}
}
onDoubleClicked:
{
tableBase.onDoubleClicked(row);
}
}
}
Connections
{
target: model
function onRowCountChanged()
{
tableView.contentY = 0; //When the number of rows is reduced, make sure to scroll back to the start.
}
}
}
}

View file

@ -0,0 +1,17 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
from cura.CuraApplication import CuraApplication
from UM.Message import Message
from UM.Version import Version
def getBackwardsCompatibleMessage(text: str, title: str, message_type_str: str, lifetime: Optional[int] = 30) -> Message:
if CuraApplication.getInstance().getAPIVersion() < Version("7.7.0"):
return Message(text=text, title=title, lifetime=lifetime)
else:
message_type = Message.MessageType.NEUTRAL
if ("MessageType." + message_type_str) in [str(item) for item in Message.MessageType]:
message_type = Message.MessageType[message_type_str]
return Message(text=text, title=title, lifetime=lifetime, message_type=message_type)

View file

@ -14,6 +14,7 @@ from UM.Logger import Logger
from UM.Message import Message
from UM.Scene.SceneNode import SceneNode
from cura.CuraApplication import CuraApplication
from .BackwardsCompatibleMessage import getBackwardsCompatibleMessage
from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest
from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse
from .DFPrintJobUploadRequest import DFPrintJobUploadRequest
@ -69,11 +70,11 @@ class DFFileExportAndUploadManager:
use_inactivity_timer = False
)
self._generic_success_message = Message(
self._generic_success_message = getBackwardsCompatibleMessage(
text = "Your {} uploaded to '{}'.".format("file was" if len(self._file_upload_job_metadata) <= 1 else "files were", self._library_project_name),
title = "Upload successful",
lifetime = 0,
message_type = Message.MessageType.POSITIVE
lifetime = 30,
message_type_str = "POSITIVE"
)
self._generic_success_message.addAction(
"open_df_project",
@ -217,11 +218,11 @@ class DFFileExportAndUploadManager:
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
self._file_upload_job_metadata[filename]["upload_status"] = "failed"
self._file_upload_job_metadata[filename]["upload_progress"] = 100
self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message(
self._file_upload_job_metadata[filename]["file_upload_failed_message"] = getBackwardsCompatibleMessage(
text = "Failed to export the file '{}'. The upload process is aborted.".format(filename),
title = "Export error",
lifetime = 0,
message_type = Message.MessageType.ERROR
message_type_str = "ERROR",
lifetime = 30
)
self._on_upload_error()
self._onFileUploadFinished(filename)
@ -240,11 +241,11 @@ class DFFileExportAndUploadManager:
self._file_upload_job_metadata[filename_3mf]["upload_progress"] = 100
human_readable_error = self.extractErrorTitle(reply_string)
self._file_upload_job_metadata[filename_3mf]["file_upload_failed_message"] = Message(
self._file_upload_job_metadata[filename_3mf]["file_upload_failed_message"] = getBackwardsCompatibleMessage(
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_3mf, self._library_project_name, human_readable_error),
title = "File upload error",
lifetime = 0,
message_type = Message.MessageType.ERROR
message_type_str = "ERROR",
lifetime = 30
)
self._on_upload_error()
self._onFileUploadFinished(filename_3mf)
@ -263,11 +264,11 @@ class DFFileExportAndUploadManager:
self._file_upload_job_metadata[filename_ufp]["upload_progress"] = 100
human_readable_error = self.extractErrorTitle(reply_string)
self._file_upload_job_metadata[filename_ufp]["file_upload_failed_message"] = Message(
self._file_upload_job_metadata[filename_ufp]["file_upload_failed_message"] = getBackwardsCompatibleMessage(
title = "File upload error",
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_ufp, self._library_project_name, human_readable_error),
lifetime = 0,
message_type = Message.MessageType.ERROR
message_type_str = "ERROR",
lifetime = 30
)
self._on_upload_error()
self._onFileUploadFinished(filename_ufp)
@ -300,11 +301,11 @@ class DFFileExportAndUploadManager:
self._file_upload_job_metadata[filename]["upload_status"] = "failed"
self._file_upload_job_metadata[filename]["upload_progress"] = 100
human_readable_error = self.extractErrorTitle(reply_string)
self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message(
self._file_upload_job_metadata[filename]["file_upload_failed_message"] = getBackwardsCompatibleMessage(
title = "File upload error",
text = "Failed to upload the file '{}' to '{}'. {}".format(self._file_name, self._library_project_name, human_readable_error),
lifetime = 0,
message_type = Message.MessageType.ERROR
message_type_str = "ERROR",
lifetime = 30
)
self._on_upload_error()
@ -319,7 +320,7 @@ class DFFileExportAndUploadManager:
def _onMessageActionTriggered(self, message, action):
if action == "open_df_project":
project_url = "{}/app/library/project/{}?wait_for_new_files=true".format(CuraApplication.getInstance().ultimakerDigitalFactoryUrl, self._library_project_id)
project_url = "{}/app/library/project/{}?wait_for_new_files=true&utm_source=cura&utm_medium=software&utm_campaign=saved-library-file-message".format(CuraApplication.getInstance().ultimakerDigitalFactoryUrl, self._library_project_id)
QDesktopServices.openUrl(QUrl(project_url))
message.hide()
@ -337,17 +338,17 @@ class DFFileExportAndUploadManager:
"upload_progress" : -1,
"upload_status" : "",
"file_upload_response": None,
"file_upload_success_message": Message(
"file_upload_success_message": getBackwardsCompatibleMessage(
text = "'{}' was uploaded to '{}'.".format(filename_3mf, self._library_project_name),
title = "Upload successful",
lifetime = 0,
message_type = Message.MessageType.POSITIVE
message_type_str = "POSITIVE",
lifetime = 30
),
"file_upload_failed_message": Message(
"file_upload_failed_message": getBackwardsCompatibleMessage(
text = "Failed to upload the file '{}' to '{}'.".format(filename_3mf, self._library_project_name),
title = "File upload error",
lifetime = 0,
message_type = Message.MessageType.ERROR
message_type_str = "ERROR",
lifetime = 30
)
}
job_3mf = ExportFileJob(self._file_handlers["3mf"], self._nodes, self._file_name, "3mf")
@ -361,17 +362,17 @@ class DFFileExportAndUploadManager:
"upload_progress" : -1,
"upload_status" : "",
"file_upload_response": None,
"file_upload_success_message": Message(
"file_upload_success_message": getBackwardsCompatibleMessage(
text = "'{}' was uploaded to '{}'.".format(filename_ufp, self._library_project_name),
title = "Upload successful",
lifetime = 0,
message_type = Message.MessageType.POSITIVE
message_type_str = "POSITIVE",
lifetime = 30,
),
"file_upload_failed_message": Message(
"file_upload_failed_message": getBackwardsCompatibleMessage(
text = "Failed to upload the file '{}' to '{}'.".format(filename_ufp, self._library_project_name),
title = "File upload error",
lifetime = 0,
message_type = Message.MessageType.ERROR
message_type_str = "ERROR",
lifetime = 30
)
}
job_ufp = ExportFileJob(self._file_handlers["ufp"], self._nodes, self._file_name, "ufp")

View file

@ -67,10 +67,12 @@ class DigitalFactoryApiClient:
def callbackWrap(response: Optional[Any] = None, *args, **kwargs) -> None:
if (response is not None and isinstance(response, DigitalFactoryFeatureBudgetResponse) and
response.library_max_private_projects is not None):
callback(
response.library_max_private_projects == -1 or # Note: -1 is unlimited
response.library_max_private_projects > 0)
# A user has DF access when library_max_private_projects is either -1 (unlimited) or bigger then 0
has_access = response.library_max_private_projects == -1 or response.library_max_private_projects > 0
callback(has_access)
self._library_max_private_projects = response.library_max_private_projects
# update the account with the additional user rights
self._account.updateAdditionalRight(df_access = has_access)
else:
Logger.warning(f"Digital Factory: Response is not a feature budget, likely an error: {str(response)}")
callback(False)

View file

@ -23,6 +23,7 @@ from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from cura.API import Account
from cura.CuraApplication import CuraApplication
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
from .BackwardsCompatibleMessage import getBackwardsCompatibleMessage
from .DFFileExportAndUploadManager import DFFileExportAndUploadManager
from .DigitalFactoryApiClient import DigitalFactoryApiClient
from .DigitalFactoryFileModel import DigitalFactoryFileModel
@ -260,7 +261,10 @@ class DigitalFactoryController(QObject):
"""
Error function, called whenever the retrieval of the files in a library project fails.
"""
Logger.log("w", "Failed to retrieve the list of files in project '{}' from the Digital Library".format(self._project_model._projects[self._selected_project_idx]))
try:
Logger.warning(f"Failed to retrieve the list of files in project '{self._project_model._projects[self._selected_project_idx]}' from the Digital Library")
except IndexError:
Logger.warning(f"Failed to retrieve the list of files in a project from the Digital Library. And failed to get the project too.")
self.setRetrievingFilesStatus(RetrievalStatus.Failed)
@pyqtSlot()
@ -527,11 +531,11 @@ class DigitalFactoryController(QObject):
except IOError as ex:
Logger.logException("e", "Can't write Digital Library file {0}/{1} download to temp-directory {2}.",
ex, project_name, file_name, temp_dir)
Message(
getBackwardsCompatibleMessage(
text = "Failed to write to temporary file for '{}'.".format(file_name),
title = "File-system error",
lifetime = 10,
message_type=Message.MessageType.ERROR
message_type_str="ERROR",
lifetime = 10
).show()
return
@ -542,11 +546,11 @@ class DigitalFactoryController(QObject):
f = file_name) -> None:
progress_message.hide()
Logger.error("An error {0} {1} occurred while downloading {2}/{3}".format(str(error), str(reply), p, f))
Message(
getBackwardsCompatibleMessage(
text = "Failed Digital Library download for '{}'.".format(f),
title = "Network error {}".format(error),
lifetime = 10,
message_type=Message.MessageType.ERROR
message_type_str="ERROR",
lifetime = 10
).show()
download_manager = HttpRequestManager.getInstance()
@ -591,17 +595,19 @@ class DigitalFactoryController(QObject):
if filename == "":
Logger.log("w", "The file name cannot be empty.")
Message(text = "Cannot upload file with an empty name to the Digital Library",
getBackwardsCompatibleMessage(
text = "Cannot upload file with an empty name to the Digital Library",
title = "Empty file name provided",
lifetime = 0,
message_type = Message.MessageType.ERROR).show()
message_type_str = "ERROR",
lifetime = 0
).show()
return
self._saveFileToSelectedProjectHelper(filename, formats)
def _saveFileToSelectedProjectHelper(self, filename: str, formats: List[str]) -> None:
# Indicate we have started sending a job.
self.uploadStarted.emit()
# Indicate we have started sending a job (and propagate any user file name changes back to the open project)
self.uploadStarted.emit(filename if "3mf" in formats else None)
library_project_id = self._project_model.items[self._selected_project_idx]["libraryProjectId"]
library_project_name = self._project_model.items[self._selected_project_idx]["displayName"]

View file

@ -8,6 +8,8 @@ from UM.Logger import Logger
from UM.OutputDevice import OutputDeviceError
from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice
from UM.Scene.SceneNode import SceneNode
from UM.Version import Version
from cura import ApplicationMetadata
from cura.API import Account
from cura.CuraApplication import CuraApplication
from .DigitalFactoryController import DigitalFactoryController
@ -105,8 +107,11 @@ class DigitalFactoryOutputDevice(ProjectOutputDevice):
self.enabled = logged_in and self._controller.userAccountHasLibraryAccess()
self.enabledChanged.emit()
def _onWriteStarted(self) -> None:
def _onWriteStarted(self, new_name: Optional[str] = None) -> None:
self._writing = True
if new_name and Version(ApplicationMetadata.CuraSDKVersion) >= Version("7.8.0"):
# setLastOutputName is only supported in sdk version 7.8.0 and up
self.setLastOutputName(new_name) # On saving, the user can change the name, this should propagate.
self.writeStarted.emit(self)
def _onWriteFinished(self) -> None:

Some files were not shown because too many files have changed in this diff Show more