Merge pull request #5 from Ultimaker/master

Update from master CURA Repo
This commit is contained in:
Gabriele Rossetti 2021-05-20 17:00:57 +02:00 committed by GitHub
commit 69beba448c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3939 changed files with 116882 additions and 101448 deletions

View file

@ -1,5 +1,5 @@
---
name: Bug report
name: Old Bug report
about: Create a report to help us fix issues.
title: ''
labels: 'Type: Bug'

View file

@ -1,7 +1,6 @@
name: Bug Report
description: Create a report to help us fix issues.
labels: "Type: Bug"
issue_body: true
body:
- type: markdown
attributes:
@ -15,7 +14,7 @@ body:
attributes:
label: Application Version
description: The version of Cura this issue occurs with.
placeholder: 4.8.0
placeholder: 4.9.0
validations:
required: true
- type: input
@ -56,13 +55,28 @@ body:
- type: markdown
attributes:
value: |
## Additional information & file uploads
Please be sure to add the following files:
* For slicing issues, upload a **project file** that clearly shows the bug.
To save a project file go to `File -> Save project`. Please make sure to .zip your project file. For big files you may need to use WeTransfer or similar file sharing sites.
G-code files are not project files!
* **Screenshots** of showing the problem, perhaps before/after images.
* A **log file**, see [here](https://github.com/Ultimaker/Cura#logging-issues) how to find the log file.
You can add these files and additional information that is relevant to the issue in the comments below.
* A **log file** for crashes and similar issues.
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`
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
- type: checkboxes
attributes:
label: Checklist of files to include
options:
- label: Log file
- label: Project file
- type: textarea
attributes:
label: Additional information & file uploads
description: You can add these files and additional information that is relevant to the issue in the comments below.
validations:
required: true

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Have questions or need support?
url: https://community.ultimaker.com/
about: Please get in touch on our Ultimaker Community Forum!

View file

@ -1,7 +1,6 @@
name: Feature Request
description: Suggest an idea for this project.
labels: "Type: New Feature"
issue_body: true
body:
- type: markdown
attributes:
@ -28,7 +27,7 @@ body:
- type: textarea
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered. Again, if possible, think about why these alternatives are not working out.
description: A clear and concise description of any alternative solutions or features you've considered. If possible, think about why these alternatives are not working out.
placeholder: The alternatives I've considered are...
validations:
required: true
@ -39,8 +38,7 @@ body:
placeholder: It will affect...
validations:
required: true
- type: markdown
- type: textarea
attributes:
value: |
## Additional information & file uploads
You can add pictures or files to visualize your feature request in the comments below.
label: Additional information & file uploads
description: You can add pictures or files to visualize your feature request in the comments below.

View file

@ -40,7 +40,7 @@ class Account(QObject):
"""
# The interval in which sync services are automatically triggered
SYNC_INTERVAL = 30.0 # seconds
SYNC_INTERVAL = 60.0 # seconds
Q_ENUMS(SyncState)
loginStateChanged = pyqtSignal(bool)
@ -58,6 +58,11 @@ class Account(QObject):
manualSyncEnabledChanged = pyqtSignal(bool)
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 " \
"library.project.read library.project.write cura.printjob.read cura.printjob.write " \
"cura.mesh.read cura.mesh.write"
def __init__(self, application: "CuraApplication", parent = None) -> None:
super().__init__(parent)
self._application = application
@ -79,10 +84,7 @@ class Account(QObject):
CALLBACK_PORT=self._callback_port,
CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port),
CLIENT_ID="um----------------------------ultimaker_cura",
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 "
"library.project.read library.project.write cura.printjob.read cura.printjob.write "
"cura.mesh.read cura.mesh.write",
CLIENT_SCOPES=self.CLIENT_SCOPES,
AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data",
AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root),
AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root)

View file

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

View file

@ -36,6 +36,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
node_items: A list of the nodes return by libnest2d, which contain the new positions on the buildplate
"""
spacing = int(1.5 * factor) # 1.5mm spacing.
machine_width = build_volume.getWidth()
machine_depth = build_volume.getDepth()
@ -75,7 +76,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
# Clip the disallowed areas so that they don't overlap the bounding box (The arranger chokes otherwise)
clipped_area = area.intersectionConvexHulls(build_plate_polygon)
if clipped_area.getPoints() is not None: # numpy array has to be explicitly checked against None
if clipped_area.getPoints() is not None and len(clipped_area.getPoints()) > 2: # numpy array has to be explicitly checked against None
for point in clipped_area.getPoints():
converted_points.append(Point(int(point[0] * factor), int(point[1] * factor)))
@ -88,7 +89,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
converted_points = []
hull_polygon = node.callDecoration("getConvexHull")
if hull_polygon is not None and hull_polygon.getPoints() is not None: # numpy array has to be explicitly checked against None
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))
item = Item(converted_points)
@ -99,7 +100,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
config = NfpConfig()
config.accuracy = 1.0
num_bins = nest(node_items, build_plate_bounding_box, 10000, config)
num_bins = nest(node_items, build_plate_bounding_box, spacing, config)
# Strip the fixed items (previously placed) and the disallowed areas from the results again.
node_items = list(filter(lambda item: not item.isFixed(), node_items))

View file

@ -3,7 +3,7 @@
import numpy
import copy
from typing import Optional, Tuple, TYPE_CHECKING
from typing import Optional, Tuple, TYPE_CHECKING, Union
from UM.Math.Polygon import Polygon
@ -14,14 +14,14 @@ if TYPE_CHECKING:
class ShapeArray:
"""Polygon representation as an array for use with :py:class:`cura.Arranging.Arrange.Arrange`"""
def __init__(self, arr: numpy.array, offset_x: float, offset_y: float, scale: float = 1) -> None:
def __init__(self, arr: numpy.ndarray, offset_x: float, offset_y: float, scale: float = 1) -> None:
self.arr = arr
self.offset_x = offset_x
self.offset_y = offset_y
self.scale = scale
@classmethod
def fromPolygon(cls, vertices: numpy.array, scale: float = 1) -> "ShapeArray":
def fromPolygon(cls, vertices: numpy.ndarray, scale: float = 1) -> "ShapeArray":
"""Instantiate from a bunch of vertices
:param vertices:
@ -98,7 +98,7 @@ class ShapeArray:
return offset_shape_arr, hull_shape_arr
@classmethod
def arrayFromPolygon(cls, shape: Tuple[int, int], vertices: numpy.array) -> numpy.array:
def arrayFromPolygon(cls, shape: Union[Tuple[int, int], numpy.ndarray], vertices: numpy.ndarray) -> numpy.ndarray:
"""Create :py:class:`numpy.ndarray` with dimensions defined by shape
Fills polygon defined by vertices with ones, all other values zero
@ -110,7 +110,7 @@ class ShapeArray:
:return: numpy array with dimensions defined by shape
"""
base_array = numpy.zeros(shape, dtype = numpy.int32) # Initialize your array of zeros
base_array = numpy.zeros(shape, dtype = numpy.int32) # type: ignore # Initialize your array of zeros
fill = numpy.ones(base_array.shape) * True # Initialize boolean array defining shape fill
@ -126,7 +126,7 @@ class ShapeArray:
return base_array
@classmethod
def _check(cls, p1: numpy.array, p2: numpy.array, base_array: numpy.array) -> Optional[numpy.array]:
def _check(cls, p1: numpy.ndarray, p2: numpy.ndarray, base_array: numpy.ndarray) -> Optional[numpy.ndarray]:
"""Return indices that mark one side of the line, used by arrayFromPolygon
Uses the line defined by p1 and p2 to check array of

View file

@ -1,4 +1,4 @@
# Copyright (c) 2019 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
@ -6,6 +6,8 @@ from typing import Any, TYPE_CHECKING
from UM.Logger import Logger
import time
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
@ -56,8 +58,8 @@ class AutoSave:
def _onTimeout(self) -> None:
self._saving = True # To prevent the save process from triggering another autosave.
Logger.log("d", "Autosaving preferences, instances and profiles")
save_start_time = time.time()
self._application.saveSettings()
Logger.log("d", "Autosaving preferences, instances and profiles took %s seconds", time.time() - save_start_time)
self._saving = False

View file

@ -5,6 +5,7 @@ import io
import os
import re
import shutil
from copy import deepcopy
from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile
from typing import Dict, Optional, TYPE_CHECKING
@ -27,6 +28,9 @@ class Backup:
IGNORED_FILES = [r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc"]
"""These files should be ignored when making a backup."""
SECRETS_SETTINGS = ["general/ultimaker_auth_data"]
"""Secret preferences that need to obfuscated when making a backup of Cura"""
catalog = i18nCatalog("cura")
"""Re-use translation catalog"""
@ -43,6 +47,9 @@ class Backup:
Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir)
# obfuscate sensitive secrets
secrets = self._obfuscate()
# Ensure all current settings are saved.
self._application.saveSettings()
@ -78,6 +85,8 @@ class Backup:
"profile_count": str(profile_count),
"plugin_count": str(plugin_count)
}
# Restore the obfuscated settings
self._illuminate(**secrets)
def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]:
"""Make a full archive from the given root path with the given name.
@ -134,8 +143,16 @@ class Backup:
"Tried to restore a Cura backup that is higher than the current version."))
return False
# Get the current secrets and store since the back-up doesn't contain those
secrets = self._obfuscate()
version_data_dir = Resources.getDataStoragePath()
archive = ZipFile(io.BytesIO(self.zip_file), "r")
try:
archive = ZipFile(io.BytesIO(self.zip_file), "r")
except LookupError as e:
Logger.log("d", f"The following error occurred while trying to restore a Cura backup: {str(e)}")
self._showMessage(self.catalog.i18nc("@info:backup_failed", "The following error occurred while trying to restore a Cura backup:") + str(e))
return False
extracted = self._extractArchive(archive, version_data_dir)
# Under Linux, preferences are stored elsewhere, so we copy the file to there.
@ -146,6 +163,9 @@ class Backup:
Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file)
shutil.move(backup_preferences_file, preferences_file)
# Restore the obfuscated settings
self._illuminate(**secrets)
return extracted
@staticmethod
@ -173,3 +193,28 @@ class Backup:
Logger.logException("e", "Unable to extract the backup due to permission or file system errors.")
return False
return True
def _obfuscate(self) -> Dict[str, str]:
"""
Obfuscate and remove the secret preferences that are specified in SECRETS_SETTINGS
:return: a dictionary of the removed secrets. Note: the '/' is replaced by '__'
"""
preferences = self._application.getPreferences()
secrets = {}
for secret in self.SECRETS_SETTINGS:
secrets[secret.replace("/", "__")] = deepcopy(preferences.getValue(secret))
preferences.setValue(secret, None)
self._application.savePreferences()
return secrets
def _illuminate(self, **kwargs) -> None:
"""
Restore the obfuscated settings
:param kwargs: a dict of obscured preferences. Note: the '__' of the keys will be replaced by '/'
"""
preferences = self._application.getPreferences()
for key, value in kwargs.items():
preferences.setValue(key.replace("__", "/"), value)
self._application.savePreferences()

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 = 16
SettingVersion = 17
Created = False
@ -458,15 +458,16 @@ class CuraApplication(QtApplication):
self._version_upgrade_manager.setCurrentVersions(
{
("quality", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityInstanceContainer, "application/x-uranium-instancecontainer"),
("quality_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityChangesInstanceContainer, "application/x-uranium-instancecontainer"),
("intent", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.IntentInstanceContainer, "application/x-uranium-instancecontainer"),
("machine_stack", GlobalStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.MachineStack, "application/x-cura-globalstack"),
("extruder_train", ExtruderStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.ExtruderStack, "application/x-cura-extruderstack"),
("preferences", Preferences.Version * 1000000 + self.SettingVersion): (Resources.Preferences, "application/x-uranium-preferences"),
("user", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.UserInstanceContainer, "application/x-uranium-instancecontainer"),
("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"),
("quality", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityInstanceContainer, "application/x-uranium-instancecontainer"),
("quality_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityChangesInstanceContainer, "application/x-uranium-instancecontainer"),
("intent", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.IntentInstanceContainer, "application/x-uranium-instancecontainer"),
("machine_stack", GlobalStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.MachineStack, "application/x-cura-globalstack"),
("extruder_train", ExtruderStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.ExtruderStack, "application/x-cura-extruderstack"),
("preferences", Preferences.Version * 1000000 + self.SettingVersion): (Resources.Preferences, "application/x-uranium-preferences"),
("user", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.UserInstanceContainer, "application/x-uranium-instancecontainer"),
("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"),
}
)
@ -603,6 +604,15 @@ class CuraApplication(QtApplication):
@pyqtSlot()
def closeApplication(self) -> None:
Logger.log("i", "Close application")
# Workaround: Before closing the window, remove the global stack.
# This is necessary because as the main window gets closed, hundreds of QML elements get updated which often
# request the global stack. However as the Qt-side of the Machine Manager is being dismantled, the conversion of
# the Global Stack to a QObject fails.
# If instead we first take down the global stack, PyQt will just convert `None` to `null` which succeeds, and
# the QML code then gets `null` as the global stack and can deal with that as it deems fit.
self.getMachineManager().setActiveMachine(None)
main_window = self.getMainWindow()
if main_window is not None:
main_window.close()

View file

@ -65,7 +65,7 @@ class LayerPolygon:
# When type is used as index returns true if type == LayerPolygon.InfillType or type == LayerPolygon.SkinType or type == LayerPolygon.SupportInfillType
# Should be generated in better way, not hardcoded.
self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = numpy.bool)
self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = bool)
self._build_cache_line_mesh_mask = None # type: Optional[numpy.ndarray]
self._build_cache_needed_points = None # type: Optional[numpy.ndarray]
@ -73,18 +73,17 @@ class LayerPolygon:
def buildCache(self) -> None:
# For the line mesh we do not draw Infill or Jumps. Therefore those lines are filtered out.
self._build_cache_line_mesh_mask = numpy.ones(self._jump_mask.shape, dtype = bool)
mesh_line_count = numpy.sum(self._build_cache_line_mesh_mask)
self._index_begin = 0
self._index_end = mesh_line_count
self._index_end = cast(int, numpy.sum(self._build_cache_line_mesh_mask))
self._build_cache_needed_points = numpy.ones((len(self._types), 2), dtype = numpy.bool)
self._build_cache_needed_points = numpy.ones((len(self._types), 2), dtype = bool)
# Only if the type of line segment changes do we need to add an extra vertex to change colors
self._build_cache_needed_points[1:, 0][:, numpy.newaxis] = self._types[1:] != self._types[:-1]
# Mark points as unneeded if they are of types we don't want in the line mesh according to the calculated mask
numpy.logical_and(self._build_cache_needed_points, self._build_cache_line_mesh_mask, self._build_cache_needed_points )
self._vertex_begin = 0
self._vertex_end = numpy.sum( self._build_cache_needed_points )
self._vertex_end = cast(int, numpy.sum(self._build_cache_needed_points))
def build(self, vertex_offset: int, index_offset: int, vertices: numpy.ndarray, colors: numpy.ndarray, line_dimensions: numpy.ndarray, feedrates: numpy.ndarray, extruders: numpy.ndarray, line_types: numpy.ndarray, indices: numpy.ndarray) -> None:
"""Set all the arrays provided by the function caller, representing the LayerPolygon

View file

@ -19,6 +19,8 @@ class SettingVisibilityPresetsModel(QObject):
onItemsChanged = pyqtSignal()
activePresetChanged = pyqtSignal()
Version = 2
def __init__(self, preferences: Preferences, parent = None) -> None:
super().__init__(parent)

View file

@ -255,10 +255,9 @@ class AuthorizationService:
self._auth_data = auth_data
if auth_data:
self._user_profile = self.getUserProfile()
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data)))
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(auth_data.dump()))
else:
self._user_profile = None
self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)
self.accessTokenChanged.emit()

View file

@ -0,0 +1,79 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Type, TYPE_CHECKING, Optional, List
import keyring
from keyring.backend import KeyringBackend
from keyring.errors import NoKeyringError, PasswordSetError
from UM.Logger import Logger
if TYPE_CHECKING:
from cura.OAuth2.Models import BaseModel
# Need to do some extra workarounds on windows:
import sys
from UM.Platform import Platform
if Platform.isWindows() and hasattr(sys, "frozen"):
import win32timezone
from keyring.backends.Windows import WinVaultKeyring
keyring.set_keyring(WinVaultKeyring())
if Platform.isOSX() and hasattr(sys, "frozen"):
from keyring.backends.macOS import Keyring
keyring.set_keyring(Keyring())
# Even if errors happen, we don't want this stored locally:
DONT_EVER_STORE_LOCALLY: List[str] = ["refresh_token"]
class KeyringAttribute:
"""
Descriptor for attributes that need to be stored in the keyring. With Fallback behaviour to the preference cfg file
"""
def __get__(self, instance: "BaseModel", owner: type) -> Optional[str]:
if self._store_secure: # type: ignore
try:
value = keyring.get_password("cura", self._keyring_name)
return value if value != "" else None
except NoKeyringError:
self._store_secure = False
Logger.logException("w", "No keyring backend present")
return getattr(instance, self._name)
else:
return getattr(instance, self._name)
def __set__(self, instance: "BaseModel", value: Optional[str]):
if self._store_secure:
setattr(instance, self._name, None)
if value is not None:
try:
keyring.set_password("cura", self._keyring_name, value)
except PasswordSetError:
self._store_secure = False
if self._name not in DONT_EVER_STORE_LOCALLY:
setattr(instance, self._name, value)
Logger.logException("w", "Keyring access denied")
except NoKeyringError:
self._store_secure = False
if self._name not in DONT_EVER_STORE_LOCALLY:
setattr(instance, self._name, value)
Logger.logException("w", "No keyring backend present")
except BaseException as e:
# A BaseException can occur in Windows when the keyring attempts to write a token longer than 1024
# characters in the Windows Credentials Manager.
self._store_secure = False
if self._name not in DONT_EVER_STORE_LOCALLY:
setattr(instance, self._name, value)
Logger.log("w", "Keyring failed: {}".format(e))
else:
setattr(instance, self._name, value)
def __set_name__(self, owner: type, name: str):
self._name = "_{}".format(name)
self._keyring_name = name
self._store_secure = False
try:
self._store_secure = KeyringBackend.viable
except NoKeyringError:
Logger.logException("w", "Could not use keyring")
setattr(owner, self._name, None)

View file

@ -1,6 +1,8 @@
# Copyright (c) 2020 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, Dict, Any, List
from typing import Optional, Dict, Any, List, Union
from copy import deepcopy
from cura.OAuth2.KeyringAttribute import KeyringAttribute
class BaseModel:
@ -37,12 +39,29 @@ class AuthenticationResponse(BaseModel):
# Data comes from the token response with success flag and error message added.
success = True # type: bool
token_type = None # type: Optional[str]
access_token = None # type: Optional[str]
refresh_token = None # type: Optional[str]
expires_in = None # type: Optional[str]
scope = None # type: Optional[str]
err_message = None # type: Optional[str]
received_at = None # type: Optional[str]
access_token = KeyringAttribute()
refresh_token = KeyringAttribute()
def __init__(self, **kwargs: Any) -> None:
self.access_token = kwargs.pop("access_token", None)
self.refresh_token = kwargs.pop("refresh_token", None)
super(AuthenticationResponse, self).__init__(**kwargs)
def dump(self) -> Dict[str, Union[bool, Optional[str]]]:
"""
Dumps the dictionary of Authentication attributes. KeyringAttributes are transformed to public attributes
If the keyring was used, these will have a None value, otherwise they will have the secret value
:return: Dictionary of Authentication attributes
"""
dumped = deepcopy(vars(self))
dumped["access_token"] = dumped.pop("_access_token")
dumped["refresh_token"] = dumped.pop("_refresh_token")
return dumped
class ResponseStatus(BaseModel):

View file

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import os
@ -381,9 +381,10 @@ class CuraContainerRegistry(ContainerRegistry):
if profile_count > 1:
continue
# Only one profile found, this should not ever be the case, so that profile needs to be removed!
Logger.log("d", "Found an invalid quality_changes profile with the name %s. Going to remove that now", profile_name)
invalid_quality_changes = ContainerRegistry.getInstance().findContainersMetadata(name=profile_name)
self.removeContainer(invalid_quality_changes[0]["id"])
if invalid_quality_changes:
Logger.log("d", "Found an invalid quality_changes profile with the name %s. Going to remove that now", profile_name)
self.removeContainer(invalid_quality_changes[0]["id"])
@override(ContainerRegistry)
def _isMetadataValid(self, metadata: Optional[Dict[str, Any]]) -> bool:

View file

@ -25,8 +25,8 @@ class Snapshot:
pixels = numpy.frombuffer(pixel_array, dtype=numpy.uint8).reshape([height, width, 4])
# Find indices of non zero pixels
nonzero_pixels = numpy.nonzero(pixels)
min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1)
max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1)
min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1) # type: ignore
max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1) # type: ignore
return min_x, max_x, min_y, max_y

View file

@ -132,9 +132,26 @@ class ObjectsModel(ListModel):
is_group = bool(node.callDecoration("isGroup"))
name_handled_as_group = False
force_rename = False
if not is_group:
# Handle names for individual nodes
if is_group:
# Handle names for grouped nodes
original_name = self._group_name_prefix
current_name = node.getName()
if current_name.startswith(self._group_name_prefix):
# This group has a standard group name, but we may need to renumber it
name_index = int(current_name.split("#")[-1])
name_handled_as_group = True
elif not current_name:
# Force rename this group because this node has not been named as a group yet, probably because
# it's a newly created group.
name_index = 0
force_rename = True
name_handled_as_group = True
if not is_group or not name_handled_as_group:
# Handle names for individual nodes or groups that already have a non-group name
name = node.getName()
name_match = self._naming_regex.fullmatch(name)
@ -144,18 +161,6 @@ class ObjectsModel(ListModel):
else:
original_name = name_match.groups()[0]
name_index = int(name_match.groups()[1])
else:
# Handle names for grouped nodes
original_name = self._group_name_prefix
current_name = node.getName()
if current_name.startswith(self._group_name_prefix):
name_index = int(current_name.split("#")[-1])
else:
# Force rename this group because this node has not been named as a group yet, probably because
# it's a newly created group.
name_index = 0
force_rename = True
if original_name not in name_to_node_info_dict:
# Keep track of 2 things:

View file

@ -239,9 +239,6 @@ class WelcomePagesModel(ListModel):
{"id": "user_agreement",
"page_url": self._getBuiltinWelcomePagePath("UserAgreementContent.qml"),
},
{"id": "whats_new",
"page_url": self._getBuiltinWelcomePagePath("WhatsNewContent.qml"),
},
{"id": "data_collections",
"page_url": self._getBuiltinWelcomePagePath("DataCollectionsContent.qml"),
},
@ -259,13 +256,21 @@ class WelcomePagesModel(ListModel):
},
{"id": "add_cloud_printers",
"page_url": self._getBuiltinWelcomePagePath("AddCloudPrintersView.qml"),
"is_final_page": True, # If we end up in this page, the next button will close the dialog
"next_page_button_text": self._catalog.i18nc("@action:button", "Finish"),
"next_page_button_text": self._catalog.i18nc("@action:button", "Next"),
"next_page_id": "whats_new",
},
{"id": "machine_actions",
"page_url": self._getBuiltinWelcomePagePath("FirstStartMachineActionsContent.qml"),
"should_show_function": self.shouldShowMachineActions,
},
{"id": "whats_new",
"page_url": self._getBuiltinWelcomePagePath("WhatsNewContent.qml"),
"next_page_button_text": self._catalog.i18nc("@action:button", "Skip"),
},
{"id": "changelog",
"page_url": self._getBuiltinWelcomePagePath("ChangelogContent.qml"),
"next_page_button_text": self._catalog.i18nc("@action:button", "Finish"),
},
]
pages_to_show = all_pages_list

View file

@ -1,8 +1,12 @@
# Copyright (c) 2019 Ultimaker B.V.
# 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 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
@ -10,13 +14,84 @@ from .WelcomePagesModel import WelcomePagesModel
#
class WhatsNewPagesModel(WelcomePagesModel):
image_formats = [".png", ".jpg", ".jpeg", ".gif", ".svg"]
text_formats = [".txt", ".htm", ".html"]
image_key = "image"
text_key = "text"
@staticmethod
def _collectOrdinalFiles(resource_type: int, include: List[str]) -> Tuple[Dict[int, str], int]:
result = {} #type: Dict[int, str]
highest = -1
try:
folder_path = Resources.getPath(resource_type, "whats_new")
for _, _, files in os.walk(folder_path):
for filename in files:
basename = os.path.basename(filename)
base, ext = os.path.splitext(basename)
if ext not in include or not base.isdigit():
continue
page_no = int(base)
highest = max(highest, page_no)
result[page_no] = os.path.join(folder_path, filename)
except FileNotFoundError:
Logger.logException("w", "Could not find 'whats_new' folder for resource-type {0}".format(resource_type))
return result, highest
@staticmethod
def _loadText(filename: str) -> str:
result = ""
try:
with open(filename, "r", encoding="utf-8") as file:
result = file.read()
except OSError:
Logger.logException("w", "Could not open {0}".format(filename))
return result
def initialize(self) -> None:
self._pages = []
self._pages.append({"id": "whats_new",
"page_url": self._getBuiltinWelcomePagePath("WhatsNewContent.qml"),
"next_page_button_text": self._catalog.i18nc("@action:button", "Skip"),
"next_page_id": "changelog"
})
self._pages.append({"id": "changelog",
"page_url": self._getBuiltinWelcomePagePath("ChangelogContent.qml"),
"next_page_button_text": self._catalog.i18nc("@action:button", "Close"),
})
self.setItems(self._pages)
images, max_image = WhatsNewPagesModel._collectOrdinalFiles(Resources.Images, WhatsNewPagesModel.image_formats)
texts, max_text = WhatsNewPagesModel._collectOrdinalFiles(Resources.Texts, WhatsNewPagesModel.text_formats)
highest = max(max_image, max_text)
self._subpages = [] #type: List[Dict[str, Optional[str]]]
for n in range(0, highest + 1):
self._subpages.append({
WhatsNewPagesModel.image_key: None if n not in images else images[n],
WhatsNewPagesModel.text_key: None if n not in texts else self._loadText(texts[n])
})
if len(self._subpages) == 0:
self._subpages.append({WhatsNewPagesModel.text_key: "~ There Is Nothing New Under The Sun ~"})
def _getSubpageItem(self, page: int, item: str) -> Optional[str]:
if 0 <= page < self.subpageCount and item in self._subpages[page]:
return self._subpages[page][item]
else:
return None
@pyqtProperty(int, constant = True)
def subpageCount(self) -> int:
return len(self._subpages)
@pyqtSlot(int, result = str)
def getSubpageImageSource(self, page: int) -> str:
result = self._getSubpageItem(page, WhatsNewPagesModel.image_key)
return "file:///" + (result if result else Resources.getPath(Resources.Images, "cura-icon.png"))
@pyqtSlot(int, result = str)
def getSubpageText(self, page: int) -> str:
result = self._getSubpageItem(page, WhatsNewPagesModel.text_key)
return result if result else "* * *"
__all__ = ["WhatsNewPagesModel"]

View file

@ -16,14 +16,6 @@ import argparse
import faulthandler
import os
# Workaround for a race condition on certain systems where there
# is a race condition between Arcus and PyQt. Importing Arcus
# first seems to prevent Sip from going into a state where it
# tries to create PyQt objects on a non-main thread.
import Arcus # @UnusedImport
import Savitar # @UnusedImport
import pynest2d # @UnusedImport
from PyQt5.QtNetwork import QSslConfiguration, QSslSocket
from UM.Platform import Platform

View file

@ -7,7 +7,7 @@ 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-7/enable
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}"

View file

@ -1157,7 +1157,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
return
machine_manager.setQualityChangesGroup(quality_changes_group, no_dialog = True)
else:
self._quality_type_to_apply = self._quality_type_to_apply.lower()
self._quality_type_to_apply = self._quality_type_to_apply.lower() if self._quality_type_to_apply else None
quality_group_dict = container_tree.getCurrentQualityGroups()
if self._quality_type_to_apply in quality_group_dict:
quality_group = quality_group_dict[self._quality_type_to_apply]

View file

@ -419,7 +419,7 @@ UM.Dialog
width: warningLabel.height
height: width
source: UM.Theme.getIcon("notice")
source: UM.Theme.getIcon("Information")
color: palette.text
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for reading 3MF files.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for writing 3MF files.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -157,22 +157,22 @@ class AMFReader(MeshReader):
tri_faces = tri_node.faces
tri_vertices = tri_node.vertices
indices = []
vertices = []
indices_list = []
vertices_list = []
index_count = 0
face_count = 0
for tri_face in tri_faces:
face = []
for tri_index in tri_face:
vertices.append(tri_vertices[tri_index])
vertices_list.append(tri_vertices[tri_index])
face.append(index_count)
index_count += 1
indices.append(face)
indices_list.append(face)
face_count += 1
vertices = numpy.asarray(vertices, dtype = numpy.float32)
indices = numpy.asarray(indices, dtype = numpy.int32)
vertices = numpy.asarray(vertices_list, dtype = numpy.float32)
indices = numpy.asarray(indices_list, dtype = numpy.int32)
normals = calculateNormalsFromIndexedVertices(vertices, indices, face_count)
mesh_data = MeshData(vertices = vertices, indices = indices, normals = normals,file_name = file_name)

View file

@ -3,5 +3,5 @@
"author": "fieldOfView",
"version": "1.0.0",
"description": "Provides support for reading AMF files.",
"api": "7.4.0"
"api": "7.5.0"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"description": "Backup and restore your configuration.",
"version": "1.2.0",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -1,3 +1,6 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import base64
import hashlib
import threading
@ -56,14 +59,20 @@ class RestoreBackupJob(Job):
return
# We store the file in a temporary path fist to ensure integrity.
temporary_backup_file = NamedTemporaryFile(delete = False)
with open(temporary_backup_file.name, "wb") as write_backup:
app = CuraApplication.getInstance()
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
while bytes_read:
write_backup.write(bytes_read)
try:
temporary_backup_file = NamedTemporaryFile(delete = False)
with open(temporary_backup_file.name, "wb") as write_backup:
app = CuraApplication.getInstance()
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
app.processEvents()
while bytes_read:
write_backup.write(bytes_read)
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
app.processEvents()
except EnvironmentError as e:
Logger.log("e", f"Unable to save backed up files due to computer limitations: {str(e)}")
self.restore_backup_error_message = self.DEFAULT_ERROR_MESSAGE
self._job_done.set()
return
if not self._verifyMd5Hash(temporary_backup_file.name, self._backup.get("md5_hash", "")):
# Don't restore the backup if the MD5 hashes do not match.

View file

@ -20,7 +20,7 @@ RowLayout
{
id: infoButton
text: catalog.i18nc("@button", "Want more?")
iconSource: UM.Theme.getIcon("info")
iconSource: UM.Theme.getIcon("Information")
onClicked: Qt.openUrlExternally("https://goo.gl/forms/QACEP8pP3RV60QYG2")
visible: backupListFooter.showInfoButton
}
@ -29,7 +29,7 @@ RowLayout
{
id: createBackupButton
text: catalog.i18nc("@button", "Backup Now")
iconSource: UM.Theme.getIcon("plus")
iconSource: UM.Theme.getIcon("Plus")
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
onClicked: CuraDrive.createBackup()
busy: CuraDrive.isCreatingBackup

View file

@ -38,7 +38,7 @@ Item
height: UM.Theme.getSize("section_icon").height
color: UM.Theme.getColor("small_button_text")
hoverColor: UM.Theme.getColor("small_button_text_hover")
iconSource: UM.Theme.getIcon("info")
iconSource: UM.Theme.getIcon("Information")
onClicked: backupListItem.showDetails = !backupListItem.showDetails
}
@ -79,7 +79,7 @@ Item
height: UM.Theme.getSize("message_close").height
color: UM.Theme.getColor("small_button_text")
hoverColor: UM.Theme.getColor("small_button_text_hover")
iconSource: UM.Theme.getIcon("cross1")
iconSource: UM.Theme.getIcon("Cancel")
onClicked: confirmDeleteDialog.visible = true
}
}

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 QtQuick 2.7
@ -17,7 +17,7 @@ ColumnLayout
// Cura version
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("application")
iconSource: UM.Theme.getIcon("UltimakerCura")
label: catalog.i18nc("@backuplist:label", "Cura Version")
value: backupDetailsData.metadata.cura_release
}
@ -25,7 +25,7 @@ ColumnLayout
// Machine count.
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("printer_single")
iconSource: UM.Theme.getIcon("Printer")
label: catalog.i18nc("@backuplist:label", "Machines")
value: backupDetailsData.metadata.machine_count
}
@ -33,7 +33,7 @@ ColumnLayout
// Material count
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("category_material")
iconSource: UM.Theme.getIcon("Spool")
label: catalog.i18nc("@backuplist:label", "Materials")
value: backupDetailsData.metadata.material_count
}
@ -41,7 +41,7 @@ ColumnLayout
// Profile count.
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("settings")
iconSource: UM.Theme.getIcon("Sliders")
label: catalog.i18nc("@backuplist:label", "Profiles")
value: backupDetailsData.metadata.profile_count
}
@ -49,7 +49,7 @@ ColumnLayout
// Plugin count.
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("plugin")
iconSource: UM.Theme.getIcon("Plugin")
label: catalog.i18nc("@backuplist:label", "Plugins")
value: backupDetailsData.metadata.plugin_count
}

View file

@ -2,7 +2,7 @@
"name": "CuraEngine Backend",
"author": "Ultimaker B.V.",
"description": "Provides the link to the CuraEngine slicing backend.",
"api": "7.4.0",
"api": "7.5.0",
"version": "1.0.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for importing Cura profiles.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for exporting Cura profiles.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog":"cura"
}

View file

@ -0,0 +1,17 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.MimeTypeDatabase import MimeType, MimeTypeDatabase
from .src import DigitalFactoryFileProvider, DigitalFactoryOutputDevicePlugin, DigitalFactoryController
def getMetaData():
return {}
def register(app):
df_controller = DigitalFactoryController.DigitalFactoryController(app)
return {
"file_provider": DigitalFactoryFileProvider.DigitalFactoryFileProvider(df_controller),
"output_device": DigitalFactoryOutputDevicePlugin.DigitalFactoryOutputDevicePlugin(df_controller)
}

View file

@ -0,0 +1,8 @@
{
"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",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<polygon style="fill:#000E1A;" points="19.7,13.3 18.3,11.9 13,17.2 13,3 11,3 11,17.2 5.7,11.9 4.3,13.3 12,21 "/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<path d="M24,44,7,33.4V14.6L24,4,41,14.6V33.4ZM9,32.3l15,9.3,15-9.3V15.7L24,6.4,9,15.7Z"/>
</svg>

After

Width:  |  Height:  |  Size: 184 B

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<path d="M12,19.6L4.4,12L7,9.4V12h2V6H3v2h2.6L3.7,9.9c-1.2,1.2-1.2,3.1,0,4.2l6.2,6.2c1.2,1.2,3.1,1.2,4.2,0l0.6-0.6l-1.4-1.4
L12,19.6z"/>
<path d="M20.3,9.9l-6.2-6.2c-1.2-1.2-3.1-1.2-4.2,0L9.3,4.3l1.4,1.4L12,4.4l7.6,7.6L17,14.6V12h-2v6h6v-2h-2.6l1.9-1.9
C21.5,12.9,21.5,11.1,20.3,9.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 646 B

View file

@ -0,0 +1,159 @@
// Copyright (C) 2021 Ultimaker B.V.
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
import DigitalFactory 1.0 as DF
Popup
{
id: base
padding: UM.Theme.getSize("default_margin").width
closePolicy: Popup.CloseOnEscape
focus: true
modal: true
background: Cura.RoundedRectangle
{
cornerSide: Cura.RoundedRectangle.Direction.All
border.color: UM.Theme.getColor("lining")
border.width: UM.Theme.getSize("default_lining").width
radius: UM.Theme.getSize("default_radius").width
width: parent.width
height: parent.height
color: UM.Theme.getColor("main_background")
}
Connections
{
target: manager
function onCreatingNewProjectStatusChanged(status)
{
if (status == DF.RetrievalStatus.Success)
{
base.close();
}
}
}
onOpened:
{
newProjectNameTextField.text = ""
newProjectNameTextField.focus = true
}
Label
{
id: createNewLibraryProjectLabel
text: "Create new Library project"
font: UM.Theme.getFont("medium")
color: UM.Theme.getColor("small_button_text")
anchors
{
top: parent.top
left: parent.left
right: parent.right
}
}
Label
{
id: projectNameLabel
text: "Project Name"
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("text")
anchors
{
top: createNewLibraryProjectLabel.bottom
topMargin: UM.Theme.getSize("default_margin").width
left: parent.left
right: parent.right
}
}
Cura.TextField
{
id: newProjectNameTextField
width: parent.width
anchors
{
top: projectNameLabel.bottom
topMargin: UM.Theme.getSize("thin_margin").width
left: parent.left
right: parent.right
}
validator: RegExpValidator
{
regExp: /^[^\\\/\*\?\|\[\]]{0,96}$/
}
text: PrintInformation.jobName
font: UM.Theme.getFont("default")
placeholderText: "Enter a name for your new project."
onAccepted:
{
if (verifyProjectCreationButton.enabled)
{
verifyProjectCreationButton.clicked()
}
}
}
Label
{
id: errorWhileCreatingProjectLabel
text: manager.projectCreationErrorText
width: parent.width
wrapMode: Text.WordWrap
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("error")
visible: manager.creatingNewProjectStatus == DF.RetrievalStatus.Failed
anchors
{
top: newProjectNameTextField.bottom
left: parent.left
right: parent.right
}
}
Cura.SecondaryButton
{
id: cancelProjectCreationButton
anchors.bottom: parent.bottom
anchors.left: parent.left
text: "Cancel"
onClicked:
{
base.close()
}
busy: false
}
Cura.PrimaryButton
{
id: verifyProjectCreationButton
anchors.bottom: parent.bottom
anchors.right: parent.right
text: "Create"
enabled: newProjectNameTextField.text != "" && !busy
onClicked:
{
manager.createLibraryProjectAndSetAsPreselected(newProjectNameTextField.text)
}
busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress
}
}

View file

@ -0,0 +1,61 @@
// Copyright (C) 2021 Ultimaker B.V.
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
import DigitalFactory 1.0 as DF
Window
{
id: digitalFactoryOpenDialogBase
title: "Open file from Library"
modality: Qt.ApplicationModal
width: 800 * screenScaleFactor
height: 600 * screenScaleFactor
minimumWidth: 800 * screenScaleFactor
minimumHeight: 600 * screenScaleFactor
Shortcut
{
sequence: "Esc"
onActivated: digitalFactoryOpenDialogBase.close()
}
color: UM.Theme.getColor("main_background")
SelectProjectPage
{
visible: manager.selectedProjectIndex == -1
createNewProjectButtonVisible: false
}
OpenProjectFilesPage
{
visible: manager.selectedProjectIndex >= 0
onOpenFilePressed: digitalFactoryOpenDialogBase.close()
}
BusyIndicator
{
// Shows up while Cura is waiting to receive the user's projects from the digital factory library
id: retrievingProjectsBusyIndicator
anchors {
verticalCenter: parent.verticalCenter
horizontalCenter: parent.horizontalCenter
}
width: parent.width / 4
height: width
visible: manager.retrievingProjectsStatus == DF.RetrievalStatus.InProgress
running: visible
palette.dark: UM.Theme.getColor("text")
}
}

View file

@ -0,0 +1,62 @@
// Copyright (C) 2021 Ultimaker B.V.
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
import DigitalFactory 1.0 as DF
Window
{
id: digitalFactorySaveDialogBase
title: "Save Cura project to Library"
modality: Qt.ApplicationModal
width: 800 * screenScaleFactor
height: 600 * screenScaleFactor
minimumWidth: 800 * screenScaleFactor
minimumHeight: 600 * screenScaleFactor
Shortcut
{
sequence: "Esc"
onActivated: digitalFactorySaveDialogBase.close()
}
color: UM.Theme.getColor("main_background")
SelectProjectPage
{
visible: manager.selectedProjectIndex == -1
createNewProjectButtonVisible: true
}
SaveProjectFilesPage
{
visible: manager.selectedProjectIndex >= 0
onSavePressed: digitalFactorySaveDialogBase.close()
onSelectDifferentProjectPressed: manager.clearProjectSelection()
}
BusyIndicator
{
// Shows up while Cura is waiting to receive the user's projects from the digital factory library
id: retrievingProjectsBusyIndicator
anchors {
verticalCenter: parent.verticalCenter
horizontalCenter: parent.horizontalCenter
}
width: parent.width / 4
height: width
visible: manager.retrievingProjectsStatus == DF.RetrievalStatus.InProgress
running: visible
palette.dark: UM.Theme.getColor("text")
}
}

View file

@ -0,0 +1,129 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Controls 2.3
import UM 1.2 as UM
import Cura 1.6 as Cura
Cura.RoundedRectangle
{
id: base
cornerSide: Cura.RoundedRectangle.Direction.All
border.color: UM.Theme.getColor("lining")
border.width: UM.Theme.getSize("default_lining").width
radius: UM.Theme.getSize("default_radius").width
signal clicked()
property var hasMoreProjectsToLoad
enabled: hasMoreProjectsToLoad
color: UM.Theme.getColor("main_background")
MouseArea
{
id: cardMouseArea
anchors.fill: parent
hoverEnabled: true
}
Row
{
id: projectInformationRow
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
UM.RecolorImage
{
id: projectImage
anchors.verticalCenter: parent.verticalCenter
width: UM.Theme.getSize("section").height
height: width
color: UM.Theme.getColor("text_link")
source: "../images/arrow_down.svg"
}
Label
{
id: displayNameLabel
anchors.verticalCenter: parent.verticalCenter
text: "Load more projects"
color: UM.Theme.getColor("text_link")
font: UM.Theme.getFont("medium_bold")
}
}
Component.onCompleted:
{
cardMouseArea.clicked.connect(base.clicked)
}
states:
[
State
{
name: "canLoadMoreProjectsAndHovered";
when: base.hasMoreProjectsToLoad && cardMouseArea.containsMouse
PropertyChanges
{
target: projectImage
color: UM.Theme.getColor("text_link")
source: "../images/arrow_down.svg"
}
PropertyChanges
{
target: displayNameLabel
color: UM.Theme.getColor("text_link")
text: "Load more projects"
}
PropertyChanges
{
target: base
color: UM.Theme.getColor("action_button_hovered")
}
},
State
{
name: "canLoadMoreProjectsAndNotHovered";
when: base.hasMoreProjectsToLoad && !cardMouseArea.containsMouse
PropertyChanges
{
target: projectImage
color: UM.Theme.getColor("text_link")
source: "../images/arrow_down.svg"
}
PropertyChanges
{
target: displayNameLabel
color: UM.Theme.getColor("text_link")
text: "Load more projects"
}
PropertyChanges
{
target: base
color: UM.Theme.getColor("main_background")
}
},
State
{
name: "noMoreProjectsToLoad"
when: !base.hasMoreProjectsToLoad
PropertyChanges
{
target: projectImage
color: UM.Theme.getColor("action_button_disabled_text")
source: "../images/update.svg"
}
PropertyChanges
{
target: displayNameLabel
color: UM.Theme.getColor("action_button_disabled_text")
text: "No more projects to load"
}
PropertyChanges
{
target: base
color: UM.Theme.getColor("action_button_disabled")
}
}
]
}

View file

@ -0,0 +1,198 @@
// Copyright (C) 2021 Ultimaker B.V.
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
import DigitalFactory 1.0 as DF
Item
{
id: base
width: parent.width
height: parent.height
property var fileModel: manager.digitalFactoryFileModel
signal openFilePressed()
signal selectDifferentProjectPressed()
anchors
{
fill: parent
margins: UM.Theme.getSize("default_margin").width
}
ProjectSummaryCard
{
id: projectSummaryCard
anchors.top: parent.top
property var selectedItem: manager.digitalFactoryProjectModel.getItem(manager.selectedProjectIndex)
imageSource: selectedItem.thumbnailUrl || "../images/placeholder.svg"
projectNameText: selectedItem.displayName || ""
projectUsernameText: selectedItem.username || ""
projectLastUpdatedText: "Last updated: " + selectedItem.lastUpdated
cardMouseAreaEnabled: false
}
Rectangle
{
id: projectFilesContent
width: parent.width
anchors.top: projectSummaryCard.bottom
anchors.topMargin: UM.Theme.getSize("default_margin").width
anchors.bottom: selectDifferentProjectButton.top
anchors.bottomMargin: UM.Theme.getSize("default_margin").width
color: UM.Theme.getColor("main_background")
border.width: UM.Theme.getSize("default_lining").width
border.color: UM.Theme.getColor("lining")
Cura.TableView
{
id: filesTableView
anchors.fill: parent
model: manager.digitalFactoryFileModel
visible: model.count != 0 && manager.retrievingFileStatus != DF.RetrievalStatus.InProgress
selectionMode: OldControls.SelectionMode.SingleSelection
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
{
id: emptyProjectLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
text: "Select a project to view its files."
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("setting_category_text")
Connections
{
target: manager
function onSelectedProjectIndexChanged(newProjectIndex)
{
emptyProjectLabel.visible = (newProjectIndex == -1)
}
}
}
Label
{
id: noFilesInProjectLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
visible: (manager.digitalFactoryFileModel.count == 0 && !emptyProjectLabel.visible && !retrievingFilesBusyIndicator.visible)
text: "No supported files in this project."
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("setting_category_text")
}
BusyIndicator
{
// Shows up while Cura is waiting to receive the files of a project from the digital factory library
id: retrievingFilesBusyIndicator
anchors
{
verticalCenter: parent.verticalCenter
horizontalCenter: parent.horizontalCenter
}
width: parent.width / 4
height: width
visible: manager.retrievingFilesStatus == DF.RetrievalStatus.InProgress
running: visible
palette.dark: UM.Theme.getColor("text")
}
Connections
{
target: manager.digitalFactoryFileModel
function onItemsChanged()
{
// Make sure no files are selected when the file model changes
filesTableView.currentRow = -1
filesTableView.selection.clear()
}
}
}
Cura.SecondaryButton
{
id: selectDifferentProjectButton
anchors.bottom: parent.bottom
anchors.left: parent.left
text: "Change Library project"
onClicked:
{
manager.clearProjectSelection()
}
busy: false
}
Cura.PrimaryButton
{
id: openFilesButton
anchors.bottom: parent.bottom
anchors.right: parent.right
text: "Open"
enabled: filesTableView.selection.count > 0
onClicked:
{
manager.openSelectedFiles()
}
busy: false
}
Component.onCompleted:
{
openFilesButton.clicked.connect(base.openFilePressed)
selectDifferentProjectButton.clicked.connect(base.selectDifferentProjectPressed)
}
}

View file

@ -0,0 +1,92 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Controls 2.3
import UM 1.2 as UM
import Cura 1.6 as Cura
Cura.RoundedRectangle
{
id: base
width: parent.width
height: projectImage.height + 2 * UM.Theme.getSize("default_margin").width
cornerSide: Cura.RoundedRectangle.Direction.All
border.color: UM.Theme.getColor("lining")
border.width: UM.Theme.getSize("default_lining").width
radius: UM.Theme.getSize("default_radius").width
color: UM.Theme.getColor("main_background")
signal clicked()
property alias imageSource: projectImage.source
property alias projectNameText: displayNameLabel.text
property alias projectUsernameText: usernameLabel.text
property alias projectLastUpdatedText: lastUpdatedLabel.text
property alias cardMouseAreaEnabled: cardMouseArea.enabled
onVisibleChanged: color = UM.Theme.getColor("main_background")
MouseArea
{
id: cardMouseArea
anchors.fill: parent
hoverEnabled: true
onEntered: base.color = UM.Theme.getColor("action_button_hovered")
onExited: base.color = UM.Theme.getColor("main_background")
onClicked: base.clicked()
}
Row
{
id: projectInformationRow
width: parent.width
padding: UM.Theme.getSize("default_margin").width
spacing: UM.Theme.getSize("default_margin").width
Image
{
id: projectImage
anchors.verticalCenter: parent.verticalCenter
width: UM.Theme.getSize("toolbox_thumbnail_small").width
height: Math.round(width * 3/4)
sourceSize.width: width
sourceSize.height: height
fillMode: Image.PreserveAspectFit
mipmap: true
}
Column
{
id: projectLabelsColumn
height: projectImage.height
width: parent.width - x - UM.Theme.getSize("default_margin").width
anchors.verticalCenter: parent.verticalCenter
Label
{
id: displayNameLabel
width: parent.width
height: Math.round(parent.height / 3)
elide: Text.ElideRight
color: UM.Theme.getColor("text")
font: UM.Theme.getFont("default_bold")
}
Label
{
id: usernameLabel
width: parent.width
height: Math.round(parent.height / 3)
elide: Text.ElideRight
color: UM.Theme.getColor("small_button_text")
font: UM.Theme.getFont("default")
}
Label
{
id: lastUpdatedLabel
width: parent.width
height: Math.round(parent.height / 3)
elide: Text.ElideRight
color: UM.Theme.getColor("small_button_text")
font: UM.Theme.getFont("default")
}
}
}
}

View file

@ -0,0 +1,259 @@
// Copyright (C) 2021 Ultimaker B.V.
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
import DigitalFactory 1.0 as DF
Item
{
id: base
width: parent.width
height: parent.height
property var fileModel: manager.digitalFactoryFileModel
signal savePressed()
signal selectDifferentProjectPressed()
anchors
{
fill: parent
margins: UM.Theme.getSize("default_margin").width
}
ProjectSummaryCard
{
id: projectSummaryCard
anchors.top: parent.top
property var selectedItem: manager.digitalFactoryProjectModel.getItem(manager.selectedProjectIndex)
imageSource: selectedItem.thumbnailUrl || "../images/placeholder.svg"
projectNameText: selectedItem.displayName || ""
projectUsernameText: selectedItem.username || ""
projectLastUpdatedText: "Last updated: " + selectedItem.lastUpdated
cardMouseAreaEnabled: false
}
Label
{
id: fileNameLabel
anchors.top: projectSummaryCard.bottom
anchors.topMargin: UM.Theme.getSize("default_margin").height
text: "Cura project name"
font: UM.Theme.getFont("medium")
color: UM.Theme.getColor("text")
}
Cura.TextField
{
id: dfFilenameTextfield
width: parent.width
anchors.left: parent.left
anchors.top: fileNameLabel.bottom
anchors.topMargin: UM.Theme.getSize("thin_margin").height
validator: RegExpValidator
{
regExp: /^[^\\\/\*\?\|\[\]]{0,96}$/
}
text: PrintInformation.jobName
font: UM.Theme.getFont("medium")
placeholderText: "Enter the name of the file."
onAccepted: { if (saveButton.enabled) {saveButton.clicked()}}
}
Rectangle
{
id: projectFilesContent
width: parent.width
anchors.top: dfFilenameTextfield.bottom
anchors.topMargin: UM.Theme.getSize("wide_margin").height
anchors.bottom: selectDifferentProjectButton.top
anchors.bottomMargin: UM.Theme.getSize("default_margin").width
color: UM.Theme.getColor("main_background")
border.width: UM.Theme.getSize("default_lining").width
border.color: UM.Theme.getColor("lining")
Cura.TableView
{
id: filesTableView
anchors.fill: parent
model: manager.digitalFactoryFileModel
visible: model.count != 0 && manager.retrievingFileStatus != DF.RetrievalStatus.InProgress
selectionMode: OldControls.SelectionMode.NoSelection
OldControls.TableViewColumn
{
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"
}
}
Label
{
id: emptyProjectLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
text: "Select a project to view its files."
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("setting_category_text")
Connections
{
target: manager
function onSelectedProjectIndexChanged()
{
emptyProjectLabel.visible = (manager.newProjectIndex == -1)
}
}
}
Label
{
id: noFilesInProjectLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
visible: (manager.digitalFactoryFileModel.count == 0 && !emptyProjectLabel.visible && !retrievingFilesBusyIndicator.visible)
text: "No supported files in this project."
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("setting_category_text")
}
BusyIndicator
{
// Shows up while Cura is waiting to receive the files of a project from the digital factory library
id: retrievingFilesBusyIndicator
anchors
{
verticalCenter: parent.verticalCenter
horizontalCenter: parent.horizontalCenter
}
width: parent.width / 4
height: width
visible: manager.retrievingFilesStatus == DF.RetrievalStatus.InProgress
running: visible
palette.dark: UM.Theme.getColor("text")
}
Connections
{
target: manager.digitalFactoryFileModel
function onItemsChanged()
{
// Make sure no files are selected when the file model changes
filesTableView.currentRow = -1
filesTableView.selection.clear()
}
}
}
Cura.SecondaryButton
{
id: selectDifferentProjectButton
anchors.bottom: parent.bottom
anchors.left: parent.left
text: "Change Library project"
onClicked:
{
manager.selectedProjectIndex = -1
}
busy: false
}
Cura.PrimaryButton
{
id: saveButton
anchors.bottom: parent.bottom
anchors.right: parent.right
text: "Save"
enabled: (asProjectCheckbox.checked || asSlicedCheckbox.checked) && dfFilenameTextfield.text != ""
onClicked:
{
let saveAsFormats = [];
if (asProjectCheckbox.checked)
{
saveAsFormats.push("3mf");
}
if (asSlicedCheckbox.checked)
{
saveAsFormats.push("ufp");
}
manager.saveFileToSelectedProject(dfFilenameTextfield.text, saveAsFormats);
}
busy: false
}
Row
{
id: saveAsFormatRow
anchors.verticalCenter: saveButton.verticalCenter
anchors.right: saveButton.left
anchors.rightMargin: UM.Theme.getSize("thin_margin").height
width: childrenRect.width
spacing: UM.Theme.getSize("default_margin").width
Cura.CheckBox
{
id: asProjectCheckbox
height: UM.Theme.getSize("checkbox").height
anchors.verticalCenter: parent.verticalCenter
checked: true
text: "Save Cura project"
font: UM.Theme.getFont("medium")
}
Cura.CheckBox
{
id: asSlicedCheckbox
height: UM.Theme.getSize("checkbox").height
anchors.verticalCenter: parent.verticalCenter
enabled: UM.Backend.state == UM.Backend.Done
checked: UM.Backend.state == UM.Backend.Done
text: "Save print file"
font: UM.Theme.getFont("medium")
}
}
Component.onCompleted:
{
saveButton.clicked.connect(base.savePressed)
selectDifferentProjectButton.clicked.connect(base.selectDifferentProjectPressed)
}
}

View file

@ -0,0 +1,202 @@
// Copyright (C) 2021 Ultimaker B.V.
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
import DigitalFactory 1.0 as DF
Item
{
id: base
width: parent.width
height: parent.height
property alias createNewProjectButtonVisible: createNewProjectButton.visible
anchors
{
top: parent.top
bottom: parent.bottom
left: parent.left
right: parent.right
margins: UM.Theme.getSize("default_margin").width
}
Label
{
id: selectProjectLabel
text: "Select Project"
font: UM.Theme.getFont("medium")
color: UM.Theme.getColor("small_button_text")
anchors.top: parent.top
anchors.left: parent.left
visible: projectListContainer.visible
}
Cura.SecondaryButton
{
id: createNewProjectButton
anchors.verticalCenter: selectProjectLabel.verticalCenter
anchors.right: parent.right
text: "New Library project"
onClicked:
{
createNewProjectPopup.open()
}
busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress
}
Item
{
id: noLibraryProjectsContainer
anchors
{
top: parent.top
bottom: parent.bottom
left: parent.left
right: parent.right
}
visible: manager.digitalFactoryProjectModel.count == 0 && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
Column
{
anchors.centerIn: parent
spacing: UM.Theme.getSize("thin_margin").height
Image
{
id: digitalFactoryImage
anchors.horizontalCenter: parent.horizontalCenter
source: "../images/digital_factory.svg"
fillMode: Image.PreserveAspectFit
width: parent.width - 2 * UM.Theme.getSize("thick_margin").width
sourceSize.width: width
sourceSize.height: height
}
Label
{
id: noLibraryProjectsLabel
anchors.horizontalCenter: parent.horizontalCenter
text: "It appears that you don't have any projects in the Library yet."
font: UM.Theme.getFont("medium")
}
Cura.TertiaryButton
{
id: visitDigitalLibraryButton
anchors.horizontalCenter: parent.horizontalCenter
text: "Visit Digital Library"
onClicked: Qt.openUrlExternally(CuraApplication.ultimakerDigitalFactoryUrl + "/app/library")
}
}
}
Item
{
id: projectListContainer
anchors
{
top: selectProjectLabel.bottom
topMargin: UM.Theme.getSize("default_margin").height
bottom: parent.bottom
left: parent.left
right: parent.right
}
visible: manager.digitalFactoryProjectModel.count > 0
// Use a flickable and a column with a repeater instead of a ListView in a ScrollView, because the ScrollView cannot
// have additional children (aside from the view inside it), which wouldn't allow us to add the LoadMoreProjectsCard
// in it.
Flickable
{
id: flickableView
clip: true
contentWidth: parent.width
contentHeight: projectsListView.implicitHeight
anchors.fill: parent
ScrollBar.vertical: ScrollBar
{
// Vertical ScrollBar, styled similarly to the scrollBar in the settings panel
id: verticalScrollBar
visible: flickableView.contentHeight > flickableView.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; } }
}
}
Column
{
id: projectsListView
width: verticalScrollBar.visible ? parent.width - verticalScrollBar.width - UM.Theme.getSize("default_margin").width : parent.width
anchors.top: parent.top
spacing: UM.Theme.getSize("narrow_margin").width
Repeater
{
model: manager.digitalFactoryProjectModel
delegate: ProjectSummaryCard
{
id: projectSummaryCard
imageSource: model.thumbnailUrl || "../images/placeholder.svg"
projectNameText: model.displayName
projectUsernameText: model.username
projectLastUpdatedText: "Last updated: " + model.lastUpdated
onClicked:
{
manager.selectedProjectIndex = index
}
}
}
LoadMoreProjectsCard
{
id: loadMoreProjectsCard
height: UM.Theme.getSize("toolbox_thumbnail_small").height
width: parent.width
visible: manager.digitalFactoryProjectModel.count > 0
hasMoreProjectsToLoad: manager.hasMoreProjectsToLoad
onClicked:
{
manager.loadMoreProjects()
}
}
}
}
}
CreateNewProjectPopup
{
id: createNewProjectPopup
width: 400 * screenScaleFactor
height: 220 * screenScaleFactor
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
}
}

View file

@ -0,0 +1,74 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime, timezone
from typing import TypeVar, Dict, List, Any, Type, Union
# Type variable used in the parse methods below, which should be a subclass of BaseModel.
T = TypeVar("T", bound="BaseModel")
class BaseModel:
def __init__(self, **kwargs) -> None:
self.__dict__.update(kwargs)
self.validate()
# Validates the model, raising an exception if the model is invalid.
def validate(self) -> None:
pass
def __eq__(self, other):
"""Checks whether the two models are equal.
:param other: The other model.
:return: True if they are equal, False if they are different.
"""
return type(self) == type(other) and self.toDict() == other.toDict()
def __ne__(self, other) -> bool:
"""Checks whether the two models are different.
:param other: The other model.
:return: True if they are different, False if they are the same.
"""
return type(self) != type(other) or self.toDict() != other.toDict()
def toDict(self) -> Dict[str, Any]:
"""Converts the model into a serializable dictionary"""
return self.__dict__
@staticmethod
def parseModel(model_class: Type[T], values: Union[T, Dict[str, Any]]) -> T:
"""Parses a single model.
:param model_class: The model class.
:param values: The value of the model, which is usually a dictionary, but may also be already parsed.
:return: An instance of the model_class given.
"""
if isinstance(values, dict):
return model_class(**values)
return values
@classmethod
def parseModels(cls, model_class: Type[T], values: List[Union[T, Dict[str, Any]]]) -> List[T]:
"""Parses a list of models.
:param model_class: The model class.
:param values: The value of the list. Each value is usually a dictionary, but may also be already parsed.
:return: A list of instances of the model_class given.
"""
return [cls.parseModel(model_class, value) for value in values]
@staticmethod
def parseDate(date: Union[str, datetime]) -> datetime:
"""Parses the given date string.
:param date: The date to parse.
:return: The parsed date.
"""
if isinstance(date, datetime):
return date
return datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)

View file

@ -0,0 +1,31 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Dict, Optional, Any
from .BaseModel import BaseModel
class CloudError(BaseModel):
"""Class representing errors generated by the servers, according to the JSON-API standard."""
def __init__(self, id: str, code: str, title: str, http_status: str, detail: Optional[str] = None,
meta: Optional[Dict[str, Any]] = None, **kwargs) -> None:
"""Creates a new error object.
:param id: Unique identifier for this particular occurrence of the problem.
:param title: A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence
of the problem, except for purposes of localization.
:param code: An application-specific error code, expressed as a string value.
:param detail: A human-readable explanation specific to this occurrence of the problem. Like title, this field's
value can be localized.
:param http_status: The HTTP status code applicable to this problem, converted to string.
:param meta: Non-standard meta-information about the error, depending on the error code.
"""
self.id = id
self.code = code
self.http_status = http_status
self.title = title
self.detail = detail
self.meta = meta
super().__init__(**kwargs)

View file

@ -0,0 +1,373 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json
import threading
from json import JSONDecodeError
from typing import List, Dict, Any, Callable, Union, Optional
from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtNetwork import QNetworkReply
from UM.FileHandler.FileHandler import FileHandler
from UM.Logger import Logger
from UM.Message import Message
from UM.Scene.SceneNode import SceneNode
from cura.CuraApplication import CuraApplication
from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest
from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse
from .DFPrintJobUploadRequest import DFPrintJobUploadRequest
from .DFPrintJobUploadResponse import DFPrintJobUploadResponse
from .DigitalFactoryApiClient import DigitalFactoryApiClient
from .ExportFileJob import ExportFileJob
class DFFileExportAndUploadManager:
"""
Class responsible for exporting the scene and uploading the exported data to the Digital Factory Library. Since 3mf
and UFP files may need to be uploaded at the same time, this class keeps a single progress and success message for
both files and updates those messages according to the progress of both the file job uploads.
"""
def __init__(self, file_handlers: Dict[str, FileHandler],
nodes: List[SceneNode],
library_project_id: str,
library_project_name: str,
file_name: str,
formats: List[str],
on_upload_error: Callable[[], Any],
on_upload_success: Callable[[], Any],
on_upload_finished: Callable[[], Any] ,
on_upload_progress: Callable[[int], Any]) -> None:
self._file_handlers = file_handlers # type: Dict[str, FileHandler]
self._nodes = nodes # type: List[SceneNode]
self._library_project_id = library_project_id # type: str
self._library_project_name = library_project_name # type: str
self._file_name = file_name # type: str
self._upload_jobs = [] # type: List[ExportFileJob]
self._formats = formats # type: List[str]
self._api = DigitalFactoryApiClient(application = CuraApplication.getInstance(), on_error = lambda error: Logger.log("e", str(error)))
# Functions of the parent class that should be called based on the upload process output
self._on_upload_error = on_upload_error
self._on_upload_success = on_upload_success
self._on_upload_finished = on_upload_finished
self._on_upload_progress = on_upload_progress
# Lock used for updating the progress message (since the progress is changed by two parallel upload jobs) or
# show the success message (once both upload jobs are done)
self._message_lock = threading.Lock()
self._file_upload_job_metadata = self.initializeFileUploadJobMetadata() # type: Dict[str, Dict[str, Any]]
self.progress_message = Message(
title = "Uploading...",
text = "Uploading files to '{}'".format(self._library_project_name),
progress = -1,
lifetime = 0,
dismissable = False,
use_inactivity_timer = False
)
self._generic_success_message = Message(
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,
)
self._generic_success_message.addAction(
"open_df_project",
"Open project",
"open-folder", "Open the project containing the file in Digital Library"
)
self._generic_success_message.actionTriggered.connect(self._onMessageActionTriggered)
def _onCuraProjectFileExported(self, job: ExportFileJob) -> None:
"""Handler for when the DF Library workspace file (3MF) has been created locally.
It can now be sent over the Digital Factory API.
"""
if not job.getOutput():
self._onJobExportError(job.getFileName())
return
self._file_upload_job_metadata[job.getFileName()]["export_job_output"] = job.getOutput()
request = DFLibraryFileUploadRequest(
content_type = job.getMimeType(),
file_name = job.getFileName(),
file_size = len(job.getOutput()),
library_project_id = self._library_project_id
)
self._api.requestUpload3MF(request, on_finished = self._uploadFileData, on_error = self._onRequestUploadCuraProjectFileFailed)
def _onPrintFileExported(self, job: ExportFileJob) -> None:
"""Handler for when the DF Library print job file (UFP) has been created locally.
It can now be sent over the Digital Factory API.
"""
if not job.getOutput():
self._onJobExportError(job.getFileName())
return
self._file_upload_job_metadata[job.getFileName()]["export_job_output"] = job.getOutput()
request = DFPrintJobUploadRequest(
content_type = job.getMimeType(),
job_name = job.getFileName(),
file_size = len(job.getOutput()),
library_project_id = self._library_project_id
)
self._api.requestUploadUFP(request, on_finished = self._uploadFileData, on_error = self._onRequestUploadPrintFileFailed)
def _uploadFileData(self, file_upload_response: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse]) -> None:
"""Uploads the exported file data after the file or print job upload has been registered at the Digital Factory
Library API.
:param file_upload_response: The response received from the Digital Factory Library API.
"""
if isinstance(file_upload_response, DFLibraryFileUploadResponse):
file_name = file_upload_response.file_name
elif isinstance(file_upload_response, DFPrintJobUploadResponse):
file_name = file_upload_response.job_name if file_upload_response.job_name is not None else ""
else:
Logger.log("e", "Wrong response type received. Aborting uploading file to the Digital Library")
return
with self._message_lock:
self.progress_message.show()
self._file_upload_job_metadata[file_name]["file_upload_response"] = file_upload_response
job_output = self._file_upload_job_metadata[file_name]["export_job_output"]
with self._message_lock:
self._file_upload_job_metadata[file_name]["upload_status"] = "uploading"
self._api.uploadExportedFileData(file_upload_response,
job_output,
on_finished = self._onFileUploadFinished,
on_success = self._onUploadSuccess,
on_progress = self._onUploadProgress,
on_error = self._onUploadError)
def _onUploadProgress(self, filename: str, progress: int) -> None:
"""
Updates the progress message according to the total progress of the two files and displays it to the user. It is
made thread-safe with a lock, since the progress can be updated by two separate upload jobs
:param filename: The name of the file for which we have progress (including the extension).
:param progress: The progress percentage
"""
with self._message_lock:
self._file_upload_job_metadata[filename]["upload_progress"] = progress
self._file_upload_job_metadata[filename]["upload_status"] = "uploading"
total_progress = self.getTotalProgress()
self.progress_message.setProgress(total_progress)
self.progress_message.show()
self._on_upload_progress(progress)
def _onUploadSuccess(self, filename: str) -> None:
"""
Sets the upload status to success and the progress of the file with the given filename to 100%. This function is
should be called only if the file has uploaded all of its data successfully (i.e. no error occurred during the
upload process).
:param filename: The name of the file that was uploaded successfully (including the extension).
"""
with self._message_lock:
self._file_upload_job_metadata[filename]["upload_status"] = "success"
self._file_upload_job_metadata[filename]["upload_progress"] = 100
self._on_upload_success()
def _onFileUploadFinished(self, filename: str) -> None:
"""
Callback that makes sure the correct messages are displayed according to the statuses of the individual jobs.
This function is called whenever an upload job has finished, regardless if it had errors or was successful.
Both jobs have to have finished for the messages to show.
:param filename: The name of the file that has finished uploading (including the extension).
"""
with self._message_lock:
# All files have finished their uploading process
if all([(file_upload_job["upload_progress"] == 100 and file_upload_job["upload_status"] != "uploading") for file_upload_job in self._file_upload_job_metadata.values()]):
# Reset and hide the progress message
self.progress_message.setProgress(-1)
self.progress_message.hide()
# All files were successfully uploaded.
if all([(file_upload_job["upload_status"] == "success") for file_upload_job in self._file_upload_job_metadata.values()]):
# Show a single generic success message for all files
self._generic_success_message.show()
else: # One or more files failed to upload.
# Show individual messages for each file, according to their statuses
for filename, upload_job_metadata in self._file_upload_job_metadata.items():
if upload_job_metadata["upload_status"] == "success":
upload_job_metadata["file_upload_success_message"].show()
else:
upload_job_metadata["file_upload_failed_message"].show()
# Call the parent's finished function
self._on_upload_finished()
def _onJobExportError(self, filename: str) -> None:
"""
Displays an appropriate message when the process to export a file fails.
:param filename: The name of the file that failed to be exported (including the extension).
"""
Logger.log("d", "Error while exporting file '{}'".format(filename))
with self._message_lock:
# 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(
text = "Failed to export the file '{}'. The upload process is aborted.".format(filename),
title = "Export error",
lifetime = 0
)
self._on_upload_error()
self._onFileUploadFinished(filename)
def _onRequestUploadCuraProjectFileFailed(self, reply: "QNetworkReply", network_error: "QNetworkReply.NetworkError") -> None:
"""
Displays an appropriate message when the request to upload the Cura project file (.3mf) to the Digital Library fails.
This means that something went wrong with the initial request to create a "file" entry in the digital library.
"""
reply_string = bytes(reply.readAll()).decode()
filename_3mf = self._file_name + ".3mf"
Logger.log("d", "An error occurred while uploading the Cura project file '{}' to the Digital Library project '{}': {}".format(filename_3mf, self._library_project_id, reply_string))
with self._message_lock:
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
self._file_upload_job_metadata[filename_3mf]["upload_status"] = "failed"
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(
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_3mf, self._library_project_name, human_readable_error),
title = "File upload error",
lifetime = 0
)
self._on_upload_error()
self._onFileUploadFinished(filename_3mf)
def _onRequestUploadPrintFileFailed(self, reply: "QNetworkReply", network_error: "QNetworkReply.NetworkError") -> None:
"""
Displays an appropriate message when the request to upload the print file (.ufp) to the Digital Library fails.
This means that something went wrong with the initial request to create a "file" entry in the digital library.
"""
reply_string = bytes(reply.readAll()).decode()
filename_ufp = self._file_name + ".ufp"
Logger.log("d", "An error occurred while uploading the print job file '{}' to the Digital Library project '{}': {}".format(filename_ufp, self._library_project_id, reply_string))
with self._message_lock:
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
self._file_upload_job_metadata[filename_ufp]["upload_status"] = "failed"
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(
title = "File upload error",
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_ufp, self._library_project_name, human_readable_error),
lifetime = 0
)
self._on_upload_error()
self._onFileUploadFinished(filename_ufp)
@staticmethod
def extractErrorTitle(reply_body: Optional[str]) -> str:
error_title = ""
if reply_body:
try:
reply_dict = json.loads(reply_body)
except JSONDecodeError:
Logger.logException("w", "Unable to extract title from reply body")
return error_title
if "errors" in reply_dict and len(reply_dict["errors"]) >= 1 and "title" in reply_dict["errors"][0]:
error_title = reply_dict["errors"][0]["title"]
return error_title
def _onUploadError(self, filename: str, reply: "QNetworkReply", error: "QNetworkReply.NetworkError") -> None:
"""
Displays the given message if uploading the mesh has failed due to a generic error (i.e. lost connection).
If one of the two files fail, this error function will set its progress as finished, to make sure that the
progress message doesn't get stuck.
:param filename: The name of the file that failed to upload (including the extension).
"""
reply_string = bytes(reply.readAll()).decode()
Logger.log("d", "Error while uploading '{}' to the Digital Library project '{}'. Reply: {}".format(filename, self._library_project_id, reply_string))
with self._message_lock:
# 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
human_readable_error = self.extractErrorTitle(reply_string)
self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message(
title = "File upload error",
text = "Failed to upload the file '{}' to '{}'. {}".format(self._file_name, self._library_project_name, human_readable_error),
lifetime = 0
)
self._on_upload_error()
def getTotalProgress(self) -> int:
"""
Returns the total upload progress of all the upload jobs
:return: The average progress percentage
"""
return int(sum([file_upload_job["upload_progress"] for file_upload_job in self._file_upload_job_metadata.values()]) / len(self._file_upload_job_metadata.values()))
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)
QDesktopServices.openUrl(QUrl(project_url))
message.hide()
def start(self) -> None:
for job in self._upload_jobs:
job.start()
def initializeFileUploadJobMetadata(self) -> Dict[str, Any]:
metadata = {}
self._upload_jobs = []
if "3mf" in self._formats and "3mf" in self._file_handlers and self._file_handlers["3mf"]:
filename_3mf = self._file_name + ".3mf"
metadata[filename_3mf] = {
"export_job_output" : None,
"upload_progress" : -1,
"upload_status" : "",
"file_upload_response": None,
"file_upload_success_message": Message(
text = "'{}' was uploaded to '{}'.".format(filename_3mf, self._library_project_name),
title = "Upload successful",
lifetime = 0,
),
"file_upload_failed_message": Message(
text = "Failed to upload the file '{}' to '{}'.".format(filename_3mf, self._library_project_name),
title = "File upload error",
lifetime = 0
)
}
job_3mf = ExportFileJob(self._file_handlers["3mf"], self._nodes, self._file_name, "3mf")
job_3mf.finished.connect(self._onCuraProjectFileExported)
self._upload_jobs.append(job_3mf)
if "ufp" in self._formats and "ufp" in self._file_handlers and self._file_handlers["ufp"]:
filename_ufp = self._file_name + ".ufp"
metadata[filename_ufp] = {
"export_job_output" : None,
"upload_progress" : -1,
"upload_status" : "",
"file_upload_response": None,
"file_upload_success_message": Message(
text = "'{}' was uploaded to '{}'.".format(filename_ufp, self._library_project_name),
title = "Upload successful",
lifetime = 0,
),
"file_upload_failed_message": Message(
text = "Failed to upload the file '{}' to '{}'.".format(filename_ufp, self._library_project_name),
title = "File upload error",
lifetime = 0
)
}
job_ufp = ExportFileJob(self._file_handlers["ufp"], self._nodes, self._file_name, "ufp")
job_ufp.finished.connect(self._onPrintFileExported)
self._upload_jobs.append(job_ufp)
return metadata

View file

@ -0,0 +1,149 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
from typing import Callable, Any, cast, Optional, Union
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse
from .DFPrintJobUploadResponse import DFPrintJobUploadResponse
class DFFileUploader:
"""Class responsible for uploading meshes to the the digital factory library in separate requests."""
# The maximum amount of times to retry if the server returns one of the RETRY_HTTP_CODES
MAX_RETRIES = 10
# The HTTP codes that should trigger a retry.
RETRY_HTTP_CODES = {500, 502, 503, 504}
def __init__(self,
http: HttpRequestManager,
df_file: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse],
data: bytes,
on_finished: Callable[[str], Any],
on_success: Callable[[str], Any],
on_progress: Callable[[str, int], Any],
on_error: Callable[[str, "QNetworkReply", "QNetworkReply.NetworkError"], Any]
) -> None:
"""Creates a mesh upload object.
:param http: The network access manager that will handle the HTTP requests.
:param df_file: The file response that was received by the Digital Factory after registering the upload.
:param data: The mesh bytes to be uploaded.
:param on_finished: The method to be called when done.
:param on_success: The method to be called when the upload is successful.
:param on_progress: The method to be called when the progress changes (receives a percentage 0-100).
:param on_error: The method to be called when an error occurs.
"""
self._http = http # type: HttpRequestManager
self._df_file = df_file # type: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse]
self._file_name = ""
if isinstance(self._df_file, DFLibraryFileUploadResponse):
self._file_name = self._df_file.file_name
elif isinstance(self._df_file, DFPrintJobUploadResponse):
if self._df_file.job_name is not None:
self._file_name = self._df_file.job_name
else:
self._file_name = ""
else:
raise TypeError("Incorrect input type")
self._data = data # type: bytes
self._on_finished = on_finished
self._on_success = on_success
self._on_progress = on_progress
self._on_error = on_error
self._retries = 0
self._finished = False
def start(self) -> None:
"""Starts uploading the mesh."""
if self._finished:
# reset state.
self._retries = 0
self._finished = False
self._upload()
def stop(self):
"""Stops uploading the mesh, marking it as finished."""
Logger.log("i", "Finished uploading")
self._finished = True # Signal to any ongoing retries that we should stop retrying.
self._on_finished(self._file_name)
def _upload(self) -> None:
"""
Uploads the file to the Digital Factory Library project
"""
if self._finished:
raise ValueError("The upload is already finished")
if isinstance(self._df_file, DFLibraryFileUploadResponse):
Logger.log("i", "Uploading Cura project file '{file_name}' via link '{upload_url}'".format(file_name = self._df_file.file_name, upload_url = self._df_file.upload_url))
elif isinstance(self._df_file, DFPrintJobUploadResponse):
Logger.log("i", "Uploading Cura print file '{file_name}' via link '{upload_url}'".format(file_name = self._df_file.job_name, upload_url = self._df_file.upload_url))
self._http.put(
url = cast(str, self._df_file.upload_url),
headers_dict = {"Content-Type": cast(str, self._df_file.content_type)},
data = self._data,
callback = self._onUploadFinished,
error_callback = self._onUploadError,
upload_progress_callback = self._onUploadProgressChanged
)
def _onUploadProgressChanged(self, bytes_sent: int, bytes_total: int) -> None:
"""Handles an update to the upload progress
:param bytes_sent: The amount of bytes sent in the current request.
:param bytes_total: The amount of bytes to send in the current request.
"""
Logger.debug("Cloud upload progress %s / %s", bytes_sent, bytes_total)
if bytes_total:
self._on_progress(self._file_name, int(bytes_sent / len(self._data) * 100))
def _onUploadError(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
"""Handles an error uploading."""
body = bytes(reply.peek(reply.bytesAvailable())).decode()
Logger.log("e", "Received error while uploading: %s", body)
self._on_error(self._file_name, reply, error)
self.stop()
def _onUploadFinished(self, reply: QNetworkReply) -> None:
"""
Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed.
"""
Logger.log("i", "Finished callback %s %s",
reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString())
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) # type: Optional[int]
if not status_code:
Logger.log("e", "Reply contained no status code.")
self._onUploadError(reply, None)
return
# check if we should retry the last chunk
if self._retries < self.MAX_RETRIES and status_code in self.RETRY_HTTP_CODES:
self._retries += 1
Logger.log("i", "Retrying %s/%s request %s", self._retries, self.MAX_RETRIES, reply.url().toString())
try:
self._upload()
except ValueError: # Asynchronously it could have completed in the meanwhile.
pass
return
# Http codes that are not to be retried are assumed to be errors.
if status_code > 308:
self._onUploadError(reply, None)
return
Logger.log("d", "status_code: %s, Headers: %s, body: %s", status_code,
[bytes(header).decode() for header in reply.rawHeaderList()], bytes(reply.readAll()).decode())
self._on_success(self._file_name)
self.stop()

View file

@ -0,0 +1,16 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
# Model that represents the request to upload a file to a DF Library project
from .BaseModel import BaseModel
class DFLibraryFileUploadRequest(BaseModel):
def __init__(self, content_type: str, file_name: str, file_size: int, library_project_id: str, **kwargs) -> None:
self.content_type = content_type
self.file_name = file_name
self.file_size = file_size
self.library_project_id = library_project_id
super().__init__(**kwargs)

View file

@ -0,0 +1,49 @@
# 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
from .BaseModel import BaseModel
class DFLibraryFileUploadResponse(BaseModel):
"""
Model that represents the response received from the Digital Factory after requesting to upload a file in a Library project
"""
def __init__(self, client_id: str, content_type: str, file_id: str, file_name: str, library_project_id: str,
status: str, uploaded_at: str, user_id: str, username: str, download_url: Optional[str] = None,
file_size: Optional[int] = None, status_description: Optional[str] = None,
upload_url: Optional[str] = None, **kwargs) -> None:
"""
:param client_id: The ID of the OAuth2 client that uploaded this file
:param content_type: The content type of the Digital Library project file
:param file_id: The ID of the library project file
:param file_name: The name of the file
:param library_project_id: The ID of the library project, in which the file will be uploaded
:param status: The status of the Digital Library project file
:param uploaded_at: The time on which the file was uploaded
:param user_id: The ID of the user that uploaded this file
:param username: The user's unique username
:param download_url: A signed URL to download the resulting file. Only available when the job is finished
:param file_size: The size of the uploaded file (in bytes)
:param status_description: Contains more details about the status, e.g. the cause of failures
:param upload_url: The one-time use URL where the file must be uploaded to (only if status is uploading)
:param kwargs: Other keyword arguments that may be included in the response
"""
self.client_id = client_id # type: str
self.content_type = content_type # type: str
self.file_id = file_id # type: str
self.file_name = file_name # type: str
self.library_project_id = library_project_id # type: str
self.status = status # type: str
self.uploaded_at = self.parseDate(uploaded_at) # type: datetime
self.user_id = user_id # type: str
self.username = username # type: str
self.download_url = download_url # type: Optional[str]
self.file_size = file_size # type: Optional[int]
self.status_description = status_description # type: Optional[str]
self.upload_url = upload_url # type: Optional[str]
super().__init__(**kwargs)

View file

@ -0,0 +1,21 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from .BaseModel import BaseModel
# Model that represents the request to upload a print job to the cloud
class DFPrintJobUploadRequest(BaseModel):
def __init__(self, job_name: str, file_size: int, content_type: str, library_project_id: str, **kwargs) -> None:
"""Creates a new print job upload request.
:param job_name: The name of the print job.
:param file_size: The size of the file in bytes.
:param content_type: The content type of the print job (e.g. text/plain or application/gzip)
"""
self.job_name = job_name
self.file_size = file_size
self.content_type = content_type
self.library_project_id = library_project_id
super().__init__(**kwargs)

View file

@ -0,0 +1,35 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
from .BaseModel import BaseModel
# Model that represents the response received from the cloud after requesting to upload a print job
class DFPrintJobUploadResponse(BaseModel):
def __init__(self, job_id: str, status: str, download_url: Optional[str] = None, job_name: Optional[str] = None,
upload_url: Optional[str] = None, content_type: Optional[str] = None,
status_description: Optional[str] = None, slicing_details: Optional[dict] = None, **kwargs) -> None:
"""Creates a new print job response model.
:param job_id: The job unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='.
:param status: The status of the print job.
:param status_description: Contains more details about the status, e.g. the cause of failures.
:param download_url: A signed URL to download the resulting status. Only available when the job is finished.
:param job_name: The name of the print job.
:param slicing_details: Model for slice information.
:param upload_url: The one-time use URL where the toolpath must be uploaded to (only if status is uploading).
:param content_type: The content type of the print job (e.g. text/plain or application/gzip)
:param generated_time: The datetime when the object was generated on the server-side.
"""
self.job_id = job_id
self.status = status
self.download_url = download_url
self.job_name = job_name
self.upload_url = upload_url
self.content_type = content_type
self.status_description = status_description
self.slicing_details = slicing_details
super().__init__(**kwargs)

View file

@ -0,0 +1,319 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json
from json import JSONDecodeError
import re
from time import time
from typing import List, Any, Optional, Union, Type, Tuple, Dict, cast, TypeVar, Callable
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
from cura.CuraApplication import CuraApplication
from cura.UltimakerCloud import UltimakerCloudConstants
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
from .DFPrintJobUploadResponse import DFPrintJobUploadResponse
from .BaseModel import BaseModel
from .CloudError import CloudError
from .DFFileUploader import DFFileUploader
from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest
from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse
from .DFPrintJobUploadRequest import DFPrintJobUploadRequest
from .DigitalFactoryFileResponse import DigitalFactoryFileResponse
from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
from .PaginationLinks import PaginationLinks
from .PaginationManager import PaginationManager
CloudApiClientModel = TypeVar("CloudApiClientModel", bound=BaseModel)
"""The generic type variable used to document the methods below."""
class DigitalFactoryApiClient:
# The URL to access the digital factory.
ROOT_PATH = UltimakerCloudConstants.CuraCloudAPIRoot
CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH)
DEFAULT_REQUEST_TIMEOUT = 10 # seconds
# In order to avoid garbage collection we keep the callbacks in this list.
_anti_gc_callbacks = [] # type: List[Callable[[Any], None]]
def __init__(self, application: CuraApplication, on_error: Callable[[List[CloudError]], None], projects_limit_per_page: Optional[int] = None) -> None:
"""Initializes a new digital factory API client.
:param application:
:param on_error: The callback to be called whenever we receive errors from the server.
"""
super().__init__()
self._application = application
self._account = application.getCuraAPI().account
self._scope = JsonDecoratorScope(UltimakerCloudScope(application))
self._http = HttpRequestManager.getInstance()
self._on_error = on_error
self._file_uploader = None # type: Optional[DFFileUploader]
self._projects_pagination_mgr = PaginationManager(limit = projects_limit_per_page) if projects_limit_per_page else None # type: Optional[PaginationManager]
def getProject(self, library_project_id: str, on_finished: Callable[[DigitalFactoryProjectResponse], Any], failed: Callable) -> None:
"""
Retrieves a digital factory project by its library project id.
:param library_project_id: The id of the library project
:param on_finished: The function to be called after the result is parsed.
:param failed: The function to be called if the request fails.
"""
url = "{}/projects/{}".format(self.CURA_API_ROOT, library_project_id)
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed),
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def getProjectsFirstPage(self, on_finished: Callable[[List[DigitalFactoryProjectResponse]], Any], failed: Callable) -> None:
"""
Retrieves digital factory projects for the user that is currently logged in.
If a projects pagination manager exists, then it attempts to get the first page of the paginated projects list,
according to the limit set in the pagination manager. If there is no projects pagination manager, this function
leaves the project limit to the default set on the server side (999999).
:param on_finished: The function to be called after the result is parsed.
:param failed: The function to be called if the request fails.
"""
url = "{}/projects".format(self.CURA_API_ROOT)
if self._projects_pagination_mgr:
self._projects_pagination_mgr.reset() # reset to clear all the links and response metadata
url += "?limit={}".format(self._projects_pagination_mgr.limit)
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed, pagination_manager = self._projects_pagination_mgr),
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def getMoreProjects(self,
on_finished: Callable[[List[DigitalFactoryProjectResponse]], Any],
failed: Callable) -> None:
"""Retrieves the next page of the paginated projects list from the API, provided that there is any.
:param on_finished: The function to be called after the result is parsed.
:param failed: The function to be called if the request fails.
"""
if self.hasMoreProjectsToLoad():
url = cast(PaginationLinks, cast(PaginationManager, self._projects_pagination_mgr).links).next_page
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed, pagination_manager = self._projects_pagination_mgr),
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
else:
Logger.log("d", "There are no more projects to load.")
def hasMoreProjectsToLoad(self) -> bool:
"""
Determines whether the client can get more pages of projects list from the API.
:return: Whether there are more pages in the projects list available to be retrieved from the API.
"""
return self._projects_pagination_mgr is not None and self._projects_pagination_mgr.links is not None and self._projects_pagination_mgr.links.next_page is not None
def getListOfFilesInProject(self, library_project_id: str, on_finished: Callable[[List[DigitalFactoryFileResponse]], Any], failed: Callable) -> None:
"""Retrieves the list of files contained in the project with library_project_id from the Digital Factory Library.
:param library_project_id: The id of the digital factory library project in which the files are included
:param on_finished: The function to be called after the result is parsed.
:param failed: The function to be called if the request fails.
"""
url = "{}/projects/{}/files".format(self.CURA_API_ROOT, library_project_id)
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, DigitalFactoryFileResponse, failed),
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def _parseCallback(self,
on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]],
model: Type[CloudApiClientModel],
on_error: Optional[Callable] = None,
pagination_manager: Optional[PaginationManager] = None) -> Callable[[QNetworkReply], None]:
"""
Creates a callback function so that it includes the parsing of the response into the correct model.
The callback is added to the 'finished' signal of the reply. If a paginated request was made and a pagination
manager is given, the pagination metadata will be held there.
:param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either
a list or a single item.
:param model: The type of the model to convert the response to.
:param on_error: The callback in case the response is ... less successful.
:param pagination_manager: Holds the pagination links and metadata contained in paginated responses.
If no pagination manager is provided, the pagination metadata is ignored.
"""
def parse(reply: QNetworkReply) -> None:
self._anti_gc_callbacks.remove(parse)
# Don't try to parse the reply if we didn't get one
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None:
if on_error is not None:
on_error()
return
status_code, response = self._parseReply(reply)
if status_code >= 300 and on_error is not None:
on_error()
else:
self._parseModels(response, on_finished, model, pagination_manager = pagination_manager)
self._anti_gc_callbacks.append(parse)
return parse
@staticmethod
def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, Any]]:
"""Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well.
:param reply: The reply from the server.
:return: A tuple with a status code and a dictionary.
"""
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
try:
response = bytes(reply.readAll()).decode()
return status_code, json.loads(response)
except (UnicodeDecodeError, JSONDecodeError, ValueError) as err:
error = CloudError(code = type(err).__name__, title = str(err), http_code = str(status_code),
id = str(time()), http_status = "500")
Logger.logException("e", "Could not parse the stardust response: %s", error.toDict())
return status_code, {"errors": [error.toDict()]}
def _parseModels(self,
response: Dict[str, Any],
on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]],
model_class: Type[CloudApiClientModel],
pagination_manager: Optional[PaginationManager] = None) -> None:
"""Parses the given models and calls the correct callback depending on the result.
:param response: The response from the server, after being converted to a dict.
:param on_finished: The callback in case the response is successful.
:param model_class: The type of the model to convert the response to. It may either be a single record or a list.
:param pagination_manager: Holds the pagination links and metadata contained in paginated responses.
If no pagination manager is provided, the pagination metadata is ignored.
"""
if "data" in response:
data = response["data"]
if "meta" in response and pagination_manager:
pagination_manager.setResponseMeta(response["meta"])
if "links" in response and pagination_manager:
pagination_manager.setLinks(response["links"])
if isinstance(data, list):
results = [model_class(**c) for c in data] # type: List[CloudApiClientModel]
on_finished_list = cast(Callable[[List[CloudApiClientModel]], Any], on_finished)
on_finished_list(results)
else:
result = model_class(**data) # type: CloudApiClientModel
on_finished_item = cast(Callable[[CloudApiClientModel], Any], on_finished)
on_finished_item(result)
elif "errors" in response:
self._on_error([CloudError(**error) for error in response["errors"]])
else:
Logger.log("e", "Cannot find data or errors in the cloud response: %s", response)
def requestUpload3MF(self, request: DFLibraryFileUploadRequest,
on_finished: Callable[[DFLibraryFileUploadResponse], Any],
on_error: Optional[Callable[["QNetworkReply", "QNetworkReply.NetworkError"], None]] = None) -> None:
"""Requests the Digital Factory to register the upload of a file in a library project.
:param request: The request object.
:param on_finished: The function to be called after the result is parsed.
:param on_error: The callback in case the request fails.
"""
url = "{}/files/upload".format(self.CURA_API_ROOT)
data = json.dumps({"data": request.toDict()}).encode()
self._http.put(url,
scope = self._scope,
data = data,
callback = self._parseCallback(on_finished, DFLibraryFileUploadResponse),
error_callback = on_error,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def requestUploadUFP(self, request: DFPrintJobUploadRequest,
on_finished: Callable[[DFPrintJobUploadResponse], Any],
on_error: Optional[Callable[["QNetworkReply", "QNetworkReply.NetworkError"], None]] = None) -> None:
"""Requests the Digital Factory to register the upload of a file in a library project.
:param request: The request object.
:param on_finished: The function to be called after the result is parsed.
:param on_error: The callback in case the request fails.
"""
url = "{}/jobs/upload".format(self.CURA_API_ROOT)
data = json.dumps({"data": request.toDict()}).encode()
self._http.put(url,
scope = self._scope,
data = data,
callback = self._parseCallback(on_finished, DFPrintJobUploadResponse),
error_callback = on_error,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def uploadExportedFileData(self,
df_file_upload_response: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse],
mesh: bytes,
on_finished: Callable[[str], Any],
on_success: Callable[[str], Any],
on_progress: Callable[[str, int], Any],
on_error: Callable[[str, "QNetworkReply", "QNetworkReply.NetworkError"], Any]) -> None:
"""Uploads an exported file (in bytes) to the Digital Factory Library.
:param df_file_upload_response: The response received after requesting an upload with `self.requestUpload`.
:param mesh: The mesh data (in bytes) to be uploaded.
:param on_finished: The function to be called after the upload has finished. Called both after on_success and on_error.
It receives the name of the file that has finished uploading.
:param on_success: The function to be called if the upload was successful.
It receives the name of the file that was uploaded successfully.
:param on_progress: A function to be called during upload progress. It receives a percentage (0-100).
It receives the name of the file for which the upload progress should be updated.
:param on_error: A function to be called if the upload fails.
It receives the name of the file that produced errors during the upload process.
"""
self._file_uploader = DFFileUploader(self._http, df_file_upload_response, mesh, on_finished, on_success, on_progress, on_error)
self._file_uploader.start()
def createNewProject(self, project_name: str, on_finished: Callable[[DigitalFactoryProjectResponse], Any], on_error: Callable) -> None:
""" Create a new project in the Digital Factory.
:param project_name: Name of the new to be created project.
:param on_finished: The function to be called after the result is parsed.
:param on_error: The function to be called if anything goes wrong.
"""
display_name = re.sub(r"[^a-zA-Z0-9- ./™®ö+']", " ", project_name)
Logger.log("i", "Attempt to create new DF project '{}'.".format(display_name))
url = "{}/projects".format(self.CURA_API_ROOT)
data = json.dumps({"data": {"display_name": display_name}}).encode()
self._http.put(url,
scope = self._scope,
data = data,
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse),
error_callback = on_error,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def clear(self) -> None:
if self._projects_pagination_mgr is not None:
self._projects_pagination_mgr.reset()

View file

@ -0,0 +1,551 @@
# Copyright (c) 2021 Ultimaker B.V.
import json
import math
import os
import tempfile
import threading
from enum import IntEnum
from pathlib import Path
from typing import Optional, List, Dict, Any, cast
from PyQt5.QtCore import pyqtSignal, QObject, pyqtSlot, pyqtProperty, Q_ENUMS, QUrl
from PyQt5.QtNetwork import QNetworkReply
from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType
from UM.FileHandler.FileHandler import FileHandler
from UM.Logger import Logger
from UM.Message import Message
from UM.Scene.SceneNode import SceneNode
from UM.Signal import Signal
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from cura.API import Account
from cura.CuraApplication import CuraApplication
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
from .DFFileExportAndUploadManager import DFFileExportAndUploadManager
from .DigitalFactoryApiClient import DigitalFactoryApiClient
from .DigitalFactoryFileModel import DigitalFactoryFileModel
from .DigitalFactoryFileResponse import DigitalFactoryFileResponse
from .DigitalFactoryProjectModel import DigitalFactoryProjectModel
from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
class RetrievalStatus(IntEnum):
"""
The status of an http get request.
This is not an enum, because we want to use it in QML and QML doesn't recognize Python enums.
"""
Idle = 0
InProgress = 1
Success = 2
Failed = 3
class DFRetrievalStatus(QObject):
"""
Used as an intermediate QObject that registers the RetrievalStatus as a recognizable enum in QML, so that it can
be used within QML objects as DigitalFactory.RetrievalStatus.<status>
"""
Q_ENUMS(RetrievalStatus)
class DigitalFactoryController(QObject):
DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB
selectedProjectIndexChanged = pyqtSignal(int, arguments = ["newProjectIndex"])
"""Signal emitted whenever the selected project is changed in the projects dropdown menu"""
selectedFileIndicesChanged = pyqtSignal("QList<int>", arguments = ["newFileIndices"])
"""Signal emitted whenever the selected file is changed in the files table"""
retrievingProjectsStatusChanged = pyqtSignal(int, arguments = ["status"])
"""Signal emitted whenever the status of the 'retrieving projects' http get request is changed"""
retrievingFilesStatusChanged = pyqtSignal(int, arguments = ["status"])
"""Signal emitted whenever the status of the 'retrieving files in project' http get request is changed"""
creatingNewProjectStatusChanged = pyqtSignal(int, arguments = ["status"])
"""Signal emitted whenever the status of the 'create new library project' http get request is changed"""
hasMoreProjectsToLoadChanged = pyqtSignal()
"""Signal emitted whenever the variable hasMoreProjectsToLoad is changed. This variable is used to determine if
the paginated list of projects has more pages to show"""
preselectedProjectChanged = pyqtSignal()
"""Signal emitted whenever a preselected project is set. Whenever there is a preselected project, it means that it is
the only project in the ProjectModel. When the preselected project is invalidated, the ProjectsModel needs to be
retrieved again."""
projectCreationErrorTextChanged = pyqtSignal()
"""Signal emitted whenever the creation of a new project fails and a specific error message is returned from the
server."""
"""Signals to inform about the process of the file upload"""
uploadStarted = Signal()
uploadFileProgress = Signal()
uploadFileSuccess = Signal()
uploadFileError = Signal()
uploadFileFinished = Signal()
def __init__(self, application: CuraApplication) -> None:
super().__init__(parent = None)
self._application = application
self._dialog = None # type: Optional["QObject"]
self.file_handlers = {} # type: Dict[str, FileHandler]
self.nodes = None # type: Optional[List[SceneNode]]
self.file_upload_manager = None # type: Optional[DFFileExportAndUploadManager]
self._has_preselected_project = False # type: bool
self._api = DigitalFactoryApiClient(self._application, on_error = lambda error: Logger.log("e", str(error)), projects_limit_per_page = 20)
# Indicates whether there are more pages of projects that can be loaded from the API
self._has_more_projects_to_load = False
self._account = self._application.getInstance().getCuraAPI().account # type: Account
self._current_workspace_information = CuraApplication.getInstance().getCurrentWorkspaceInformation()
# Initialize the project model
self._project_model = DigitalFactoryProjectModel()
self._selected_project_idx = -1
self._project_creation_error_text = "Something went wrong while creating a new project. Please try again."
# Initialize the file model
self._file_model = DigitalFactoryFileModel()
self._selected_file_indices = [] # type: List[int]
# Filled after the application has been initialized
self._supported_file_types = {} # type: Dict[str, str]
# For cleaning up the files afterwards:
self._erase_temp_files_lock = threading.Lock()
# The statuses which indicate whether Cura is waiting for a response from the DigitalFactory API
self.retrieving_files_status = RetrievalStatus.Idle
self.retrieving_projects_status = RetrievalStatus.Idle
self.creating_new_project_status = RetrievalStatus.Idle
self._application.engineCreatedSignal.connect(self._onEngineCreated)
self._application.initializationFinished.connect(self._applicationInitializationFinished)
def clear(self) -> None:
self._project_model.clearProjects()
self._api.clear()
self._has_preselected_project = False
self.preselectedProjectChanged.emit()
self.setRetrievingFilesStatus(RetrievalStatus.Idle)
self.setRetrievingProjectsStatus(RetrievalStatus.Idle)
self.setCreatingNewProjectStatus(RetrievalStatus.Idle)
self.setSelectedProjectIndex(-1)
def userAccountHasLibraryAccess(self) -> bool:
"""
Checks whether the currently logged in user account has access to the Digital Library
:return: True if the user account has Digital Library access, else False
"""
subscriptions = [] # type: List[Dict[str, Any]]
if self._account.userProfile:
subscriptions = self._account.userProfile.get("subscriptions", [])
return len(subscriptions) > 0
def initialize(self, preselected_project_id: Optional[str] = None) -> None:
self.clear()
if self._account.isLoggedIn and self.userAccountHasLibraryAccess():
self.setRetrievingProjectsStatus(RetrievalStatus.InProgress)
if preselected_project_id:
self._api.getProject(preselected_project_id, on_finished = self.setProjectAsPreselected, failed = self._onGetProjectFailed)
else:
self._api.getProjectsFirstPage(on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
def setProjectAsPreselected(self, df_project: DigitalFactoryProjectResponse) -> None:
"""
Sets the received df_project as the preselected one. When a project is preselected, it should be the only
project inside the model, so this function first makes sure to clear the projects model.
:param df_project: The library project intended to be set as preselected
"""
self._project_model.clearProjects()
self._project_model.setProjects([df_project])
self.setSelectedProjectIndex(0)
self.setHasPreselectedProject(True)
self.setRetrievingProjectsStatus(RetrievalStatus.Success)
self.setCreatingNewProjectStatus(RetrievalStatus.Success)
def _onGetProjectFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
reply_string = bytes(reply.readAll()).decode()
self.setHasPreselectedProject(False)
Logger.log("w", "Something went wrong while trying to retrieve a the preselected Digital Library project. Error: {}".format(reply_string))
def _onGetProjectsFirstPageFinished(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
"""
Set the first page of projects received from the digital factory library in the project model. Called whenever
the retrieval of the first page of projects is successful.
:param df_projects: A list of all the Digital Factory Library projects linked to the user's account
"""
self.setHasMoreProjectsToLoad(self._api.hasMoreProjectsToLoad())
self._project_model.setProjects(df_projects)
self.setRetrievingProjectsStatus(RetrievalStatus.Success)
@pyqtSlot()
def loadMoreProjects(self) -> None:
"""
Initiates the process of retrieving the next page of the projects list from the API.
"""
self._api.getMoreProjects(on_finished = self.loadMoreProjectsFinished, failed = self._onGetProjectsFailed)
self.setRetrievingProjectsStatus(RetrievalStatus.InProgress)
def loadMoreProjectsFinished(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
"""
Set the projects received from the digital factory library in the project model. Called whenever the retrieval
of the projects is successful.
:param df_projects: A list of all the Digital Factory Library projects linked to the user's account
"""
self.setHasMoreProjectsToLoad(self._api.hasMoreProjectsToLoad())
self._project_model.extendProjects(df_projects)
self.setRetrievingProjectsStatus(RetrievalStatus.Success)
def _onGetProjectsFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
"""
Error function, called whenever the retrieval of projects fails.
"""
self.setRetrievingProjectsStatus(RetrievalStatus.Failed)
Logger.log("w", "Failed to retrieve the list of projects from the Digital Library. Error encountered: {}".format(error))
def getProjectFilesFinished(self, df_files_in_project: List[DigitalFactoryFileResponse]) -> None:
"""
Set the files received from the digital factory library in the file model. The files are filtered to only
contain the files which can be opened by Cura.
Called whenever the retrieval of the files is successful.
:param df_files_in_project: A list of all the Digital Factory Library files that exist in a library project
"""
# Filter to show only the files that can be opened in Cura
self._file_model.setFilters({"file_name": lambda x: Path(x).suffix[1:].lower() in self._supported_file_types}) # the suffix is in format '.xyz', so omit the dot at the start
self._file_model.setFiles(df_files_in_project)
self.setRetrievingFilesStatus(RetrievalStatus.Success)
def getProjectFilesFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
"""
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]))
self.setRetrievingFilesStatus(RetrievalStatus.Failed)
@pyqtSlot()
def clearProjectSelection(self) -> None:
"""
Clear the selected project.
"""
if self._has_preselected_project:
self.setHasPreselectedProject(False)
else:
self.setSelectedProjectIndex(-1)
@pyqtSlot(int)
def setSelectedProjectIndex(self, project_idx: int) -> None:
"""
Sets the index of the project which is currently selected in the dropdown menu. Then, it uses the project_id of
that project to retrieve the list of files included in that project and display it in the interface.
:param project_idx: The index of the currently selected project
"""
if project_idx < -1 or project_idx >= len(self._project_model.items):
Logger.log("w", "The selected project index is invalid.")
project_idx = -1 # -1 is a valid index for the combobox and it is handled as "nothing is selected"
self._selected_project_idx = project_idx
self.selectedProjectIndexChanged.emit(project_idx)
# Clear the files from the previously-selected project and refresh the files model with the newly-selected-
# project's files
self._file_model.clearFiles()
self.selectedFileIndicesChanged.emit([])
if 0 <= project_idx < len(self._project_model.items):
library_project_id = self._project_model.items[project_idx]["libraryProjectId"]
self.setRetrievingFilesStatus(RetrievalStatus.InProgress)
self._api.getListOfFilesInProject(library_project_id, on_finished = self.getProjectFilesFinished, failed = self.getProjectFilesFailed)
@pyqtProperty(int, fset = setSelectedProjectIndex, notify = selectedProjectIndexChanged)
def selectedProjectIndex(self) -> int:
return self._selected_project_idx
@pyqtSlot("QList<int>")
def setSelectedFileIndices(self, file_indices: List[int]) -> None:
"""
Sets the index of the file which is currently selected in the list of files.
:param file_indices: The index of the currently selected file
"""
if file_indices != self._selected_file_indices:
self._selected_file_indices = file_indices
self.selectedFileIndicesChanged.emit(file_indices)
@pyqtProperty(QObject, constant = True)
def digitalFactoryProjectModel(self) -> "DigitalFactoryProjectModel":
return self._project_model
@pyqtProperty(QObject, constant = True)
def digitalFactoryFileModel(self) -> "DigitalFactoryFileModel":
return self._file_model
def setHasMoreProjectsToLoad(self, has_more_projects_to_load: bool) -> None:
"""
Set the value that indicates whether there are more pages of projects that can be loaded from the API
:param has_more_projects_to_load: Whether there are more pages of projects
"""
if has_more_projects_to_load != self._has_more_projects_to_load:
self._has_more_projects_to_load = has_more_projects_to_load
self.hasMoreProjectsToLoadChanged.emit()
@pyqtProperty(bool, fset = setHasMoreProjectsToLoad, notify = hasMoreProjectsToLoadChanged)
def hasMoreProjectsToLoad(self) -> bool:
"""
:return: whether there are more pages for projects that can be loaded from the API
"""
return self._has_more_projects_to_load
@pyqtSlot(str)
def createLibraryProjectAndSetAsPreselected(self, project_name: Optional[str]) -> None:
"""
Creates a new project with the given name in the Digital Library.
:param project_name: The name that will be used for the new project
"""
if project_name:
self._api.createNewProject(project_name, self.setProjectAsPreselected, self._createNewLibraryProjectFailed)
self.setCreatingNewProjectStatus(RetrievalStatus.InProgress)
else:
Logger.log("w", "No project name provided while attempting to create a new project. Aborting the project creation.")
def _createNewLibraryProjectFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
reply_string = bytes(reply.readAll()).decode()
self._project_creation_error_text = "Something went wrong while creating the new project. Please try again."
if reply_string:
reply_dict = json.loads(reply_string)
if "errors" in reply_dict and len(reply_dict["errors"]) >= 1 and "title" in reply_dict["errors"][0]:
self._project_creation_error_text = "Error while creating the new project: {}".format(reply_dict["errors"][0]["title"])
self.projectCreationErrorTextChanged.emit()
self.setCreatingNewProjectStatus(RetrievalStatus.Failed)
Logger.log("e", "Something went wrong while trying to create a new a project. Error: {}".format(reply_string))
def setRetrievingProjectsStatus(self, new_status: RetrievalStatus) -> None:
"""
Sets the status of the "retrieving library projects" http call.
:param new_status: The new status
"""
self.retrieving_projects_status = new_status
self.retrievingProjectsStatusChanged.emit(int(new_status))
@pyqtProperty(int, fset = setRetrievingProjectsStatus, notify = retrievingProjectsStatusChanged)
def retrievingProjectsStatus(self) -> int:
return int(self.retrieving_projects_status)
def setRetrievingFilesStatus(self, new_status: RetrievalStatus) -> None:
"""
Sets the status of the "retrieving files list in the selected library project" http call.
:param new_status: The new status
"""
self.retrieving_files_status = new_status
self.retrievingFilesStatusChanged.emit(int(new_status))
@pyqtProperty(int, fset = setRetrievingFilesStatus, notify = retrievingFilesStatusChanged)
def retrievingFilesStatus(self) -> int:
return int(self.retrieving_files_status)
def setCreatingNewProjectStatus(self, new_status: RetrievalStatus) -> None:
"""
Sets the status of the "creating new library project" http call.
:param new_status: The new status
"""
self.creating_new_project_status = new_status
self.creatingNewProjectStatusChanged.emit(int(new_status))
@pyqtProperty(int, fset = setCreatingNewProjectStatus, notify = creatingNewProjectStatusChanged)
def creatingNewProjectStatus(self) -> int:
return int(self.creating_new_project_status)
@staticmethod
def _onEngineCreated() -> None:
qmlRegisterUncreatableType(DFRetrievalStatus, "DigitalFactory", 1, 0, "RetrievalStatus", "Could not create RetrievalStatus enum type")
def _applicationInitializationFinished(self) -> None:
self._supported_file_types = self._application.getInstance().getMeshFileHandler().getSupportedFileTypesRead()
@pyqtSlot()
def openSelectedFiles(self) -> None:
""" Downloads, then opens all files selected in the Qt frontend open dialog.
"""
temp_dir = tempfile.mkdtemp()
if temp_dir is None or temp_dir == "":
Logger.error("Digital Library: Couldn't create temporary directory to store to-be downloaded files.")
return
if self._selected_project_idx < 0 or len(self._selected_file_indices) < 1:
Logger.error("Digital Library: No project or no file selected on open action.")
return
to_erase_on_done_set = {
os.path.join(temp_dir, self._file_model.getItem(i)["fileName"]).replace('\\', '/')
for i in self._selected_file_indices}
def onLoadedCallback(filename_done: str) -> None:
filename_done = os.path.join(temp_dir, filename_done).replace('\\', '/')
with self._erase_temp_files_lock:
if filename_done in to_erase_on_done_set:
try:
os.remove(filename_done)
to_erase_on_done_set.remove(filename_done)
if len(to_erase_on_done_set) < 1 and os.path.exists(temp_dir):
os.rmdir(temp_dir)
except (IOError, OSError) as ex:
Logger.error("Can't erase temporary (in) {0} because {1}.", temp_dir, str(ex))
# Save the project id to make sure it will be preselected the next time the user opens the save dialog
CuraApplication.getInstance().getCurrentWorkspaceInformation().setEntryToStore("digital_factory", "library_project_id", library_project_id)
# Disconnect the signals so that they are not fired every time another (project) file is loaded
app.fileLoaded.disconnect(onLoadedCallback)
app.workspaceLoaded.disconnect(onLoadedCallback)
app = CuraApplication.getInstance()
app.fileLoaded.connect(onLoadedCallback) # fired when non-project files are loaded
app.workspaceLoaded.connect(onLoadedCallback) # fired when project files are loaded
project_name = self._project_model.getItem(self._selected_project_idx)["displayName"]
for file_index in self._selected_file_indices:
file_item = self._file_model.getItem(file_index)
file_name = file_item["fileName"]
download_url = file_item["downloadUrl"]
library_project_id = file_item["libraryProjectId"]
self._openSelectedFile(temp_dir, project_name, file_name, download_url)
def _openSelectedFile(self, temp_dir: str, project_name: str, file_name: str, download_url: str) -> None:
""" Downloads, then opens, the single specified file.
:param temp_dir: The already created temporary directory where the files will be stored.
:param project_name: Name of the project the file belongs to (used for error reporting).
:param file_name: Name of the file to be downloaded and opened (used for error reporting).
:param download_url: This url will be downloaded, then the downloaded file will be opened in Cura.
"""
if not download_url:
Logger.log("e", "No download url for file '{}'".format(file_name))
return
progress_message = Message(text = "{0}/{1}".format(project_name, file_name), dismissable = False, lifetime = 0,
progress = 0, title = "Downloading...")
progress_message.setProgress(0)
progress_message.show()
def progressCallback(rx: int, rt: int) -> None:
progress_message.setProgress(math.floor(rx * 100.0 / rt))
def finishedCallback(reply: QNetworkReply) -> None:
progress_message.hide()
try:
with open(os.path.join(temp_dir, file_name), "wb+") as temp_file:
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
while bytes_read:
temp_file.write(bytes_read)
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
CuraApplication.getInstance().processEvents()
temp_file_name = temp_file.name
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(
text = "Failed to write to temporary file for '{}'.".format(file_name),
title = "File-system error",
lifetime = 10
).show()
return
CuraApplication.getInstance().readLocalFile(
QUrl.fromLocalFile(temp_file_name), add_to_recent_files = False)
def errorCallback(reply: QNetworkReply, error: QNetworkReply.NetworkError, p = project_name,
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(
text = "Failed Digital Library download for '{}'.".format(f),
title = "Network error {}".format(error),
lifetime = 10
).show()
download_manager = HttpRequestManager.getInstance()
download_manager.get(download_url, callback = finishedCallback, download_progress_callback = progressCallback,
error_callback = errorCallback, scope = UltimakerCloudScope(CuraApplication.getInstance()))
def setHasPreselectedProject(self, new_has_preselected_project: bool) -> None:
if not new_has_preselected_project:
# The preselected project was the only one in the model, at index 0, so when we set the has_preselected_project to
# false, we also need to clean it from the projects model
self._project_model.clearProjects()
self.setSelectedProjectIndex(-1)
self._api.getProjectsFirstPage(on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
self.setRetrievingProjectsStatus(RetrievalStatus.InProgress)
self._has_preselected_project = new_has_preselected_project
self.preselectedProjectChanged.emit()
@pyqtProperty(bool, fset = setHasPreselectedProject, notify = preselectedProjectChanged)
def hasPreselectedProject(self) -> bool:
return self._has_preselected_project
@pyqtSlot(str, "QStringList")
def saveFileToSelectedProject(self, filename: str, formats: List[str]) -> None:
"""
Function triggered whenever the Save button is pressed.
:param filename: The name (without the extension) that will be used for the files
:param formats: List of the formats the scene will be exported to. Can include 3mf, ufp, or both
"""
if self._selected_project_idx == -1:
Logger.log("e", "No DF Library project is selected.")
return
if filename == "":
Logger.log("w", "The file name cannot be empty.")
Message(text = "Cannot upload file with an empty name to the Digital Library", title = "Empty file name provided", 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()
library_project_id = self._project_model.items[self._selected_project_idx]["libraryProjectId"]
library_project_name = self._project_model.items[self._selected_project_idx]["displayName"]
# Use the file upload manager to export and upload the 3mf and/or ufp files to the DF Library project
self.file_upload_manager = DFFileExportAndUploadManager(file_handlers = self.file_handlers, nodes = cast(List[SceneNode], self.nodes),
library_project_id = library_project_id,
library_project_name = library_project_name,
file_name = filename, formats = formats,
on_upload_error = self.uploadFileError.emit,
on_upload_success = self.uploadFileSuccess.emit,
on_upload_finished = self.uploadFileFinished.emit,
on_upload_progress = self.uploadFileProgress.emit)
self.file_upload_manager.start()
# Save the project id to make sure it will be preselected the next time the user opens the save dialog
self._current_workspace_information.setEntryToStore("digital_factory", "library_project_id", library_project_id)
@pyqtProperty(str, notify = projectCreationErrorTextChanged)
def projectCreationErrorText(self) -> str:
return self._project_creation_error_text

View file

@ -0,0 +1,116 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import List, Dict, Callable
from PyQt5.QtCore import Qt, pyqtSignal
from UM.Logger import Logger
from UM.Qt.ListModel import ListModel
from .DigitalFactoryFileResponse import DigitalFactoryFileResponse
DIGITAL_FACTORY_DISPLAY_DATETIME_FORMAT = "%d-%m-%Y %H:%M"
class DigitalFactoryFileModel(ListModel):
FileNameRole = Qt.UserRole + 1
FileIdRole = Qt.UserRole + 2
FileSizeRole = Qt.UserRole + 3
LibraryProjectIdRole = Qt.UserRole + 4
DownloadUrlRole = Qt.UserRole + 5
UsernameRole = Qt.UserRole + 6
UploadedAtRole = Qt.UserRole + 7
dfFileModelChanged = pyqtSignal()
def __init__(self, parent = None):
super().__init__(parent)
self.addRoleName(self.FileNameRole, "fileName")
self.addRoleName(self.FileIdRole, "fileId")
self.addRoleName(self.FileSizeRole, "fileSize")
self.addRoleName(self.LibraryProjectIdRole, "libraryProjectId")
self.addRoleName(self.DownloadUrlRole, "downloadUrl")
self.addRoleName(self.UsernameRole, "username")
self.addRoleName(self.UploadedAtRole, "uploadedAt")
self._files = [] # type: List[DigitalFactoryFileResponse]
self._filters = {} # type: Dict[str, Callable]
def setFiles(self, df_files_in_project: List[DigitalFactoryFileResponse]) -> None:
if self._files == df_files_in_project:
return
self.clear()
self._files = df_files_in_project
self._update()
def clearFiles(self) -> None:
self.clear()
self._files.clear()
self.dfFileModelChanged.emit()
def _update(self) -> None:
filtered_files_list = self.getFilteredFilesList()
for file in filtered_files_list:
self.appendItem({
"fileName" : file.file_name,
"fileId" : file.file_id,
"fileSize": file.file_size,
"libraryProjectId": file.library_project_id,
"downloadUrl": file.download_url,
"username": file.username,
"uploadedAt": file.uploaded_at.strftime(DIGITAL_FACTORY_DISPLAY_DATETIME_FORMAT)
})
self.dfFileModelChanged.emit()
def setFilters(self, filters: Dict[str, Callable]) -> None:
"""
Sets the filters and updates the files model to contain only the files that meet all of the filters.
:param filters: The filters to be applied
example:
{
"attribute_name1": function_to_be_applied_on_DigitalFactoryFileResponse_attribute1,
"attribute_name2": function_to_be_applied_on_DigitalFactoryFileResponse_attribute2
}
"""
self.clear()
self._filters = filters
self._update()
def clearFilters(self) -> None:
"""
Clears all the model filters
"""
self.setFilters({})
def getFilteredFilesList(self) -> List[DigitalFactoryFileResponse]:
"""
Lists the files that meet all the filters specified in the self._filters. This is achieved by applying each
filter function on the corresponding attribute for all the filters in the self._filters. If all of them are
true, the file is added to the filtered files list.
In order for this to work, the self._filters should be in the format:
{
"attribute_name": function_to_be_applied_on_the_DigitalFactoryFileResponse_attribute
}
:return: The list of files that meet all the specified filters
"""
if not self._filters:
return self._files
filtered_files_list = []
for file in self._files:
filter_results = []
for attribute, filter_func in self._filters.items():
try:
filter_results.append(filter_func(getattr(file, attribute)))
except AttributeError:
Logger.log("w", "Attribute '{}' doesn't exist in objects of type '{}'".format(attribute, type(file)))
all_filters_met = all(filter_results)
if all_filters_met:
filtered_files_list.append(file)
return filtered_files_list

View file

@ -0,0 +1,62 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import os
from UM.FileProvider import FileProvider
from UM.Logger import Logger
from cura.API import Account
from cura.CuraApplication import CuraApplication
from .DigitalFactoryController import DigitalFactoryController
class DigitalFactoryFileProvider(FileProvider):
def __init__(self, df_controller: DigitalFactoryController) -> None:
super().__init__()
self._controller = df_controller
self.menu_item_display_text = "From Digital Library"
self.shortcut = "Ctrl+Shift+O"
plugin_path = os.path.dirname(os.path.dirname(__file__))
self._dialog_path = os.path.join(plugin_path, "resources", "qml", "DigitalFactoryOpenDialog.qml")
self._dialog = None
self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account
self._account.loginStateChanged.connect(self._onLoginStateChanged)
self.enabled = self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess()
self.priority = 10
def run(self) -> None:
"""
Function called every time the 'From Digital Factory' option of the 'Open File(s)' submenu is triggered
"""
self.loadWindow()
if self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess():
self._controller.initialize()
if not self._dialog:
Logger.log("e", "Unable to create the Digital Library Open dialog.")
return
self._dialog.show()
def loadWindow(self) -> None:
"""
Create the GUI window for the Digital Library Open dialog. If the window is already open, bring the focus on it.
"""
if self._dialog: # Dialogue is already open.
self._dialog.requestActivate() # Bring the focus on the dialogue.
return
self._dialog = CuraApplication.getInstance().createQmlComponent(self._dialog_path, {"manager": self._controller})
if not self._dialog:
Logger.log("e", "Unable to create the Digital Library Open dialog.")
def _onLoginStateChanged(self, logged_in: bool) -> None:
"""
Sets the enabled status of the DigitalFactoryFileProvider according to the account's login status
:param logged_in: The new login status
"""
self.enabled = logged_in and self._controller.userAccountHasLibraryAccess()
self.enabledChanged.emit()

View file

@ -0,0 +1,57 @@
# 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
from .BaseModel import BaseModel
DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
class DigitalFactoryFileResponse(BaseModel):
"""Class representing a file in a digital factory project."""
def __init__(self, client_id: str, content_type: str, file_id: str, file_name: str, library_project_id: str,
status: str, user_id: str, username: str, uploaded_at: str, download_url: Optional[str] = "", status_description: Optional[str] = "",
file_size: Optional[int] = 0, upload_url: Optional[str] = "", **kwargs) -> None:
"""
Creates a new DF file response object
:param client_id:
:param content_type:
:param file_id:
:param file_name:
:param library_project_id:
:param status:
:param user_id:
:param username:
:param download_url:
:param status_description:
:param file_size:
:param upload_url:
:param kwargs:
"""
self.client_id = client_id
self.content_type = content_type
self.download_url = download_url
self.file_id = file_id
self.file_name = file_name
self.file_size = file_size
self.library_project_id = library_project_id
self.status = status
self.status_description = status_description
self.upload_url = upload_url
self.user_id = user_id
self.username = username
self.uploaded_at = datetime.strptime(uploaded_at, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT)
super().__init__(**kwargs)
def __repr__(self) -> str:
return "File: {}, from: {}, File ID: {}, Project ID: {}, Download URL: {}".format(self.file_name, self.username, self.file_id, self.library_project_id, self.download_url)
# Validates the model, raising an exception if the model is invalid.
def validate(self) -> None:
super().validate()
if not self.file_id:
raise ValueError("file_id is required in Digital Library file")

View file

@ -0,0 +1,118 @@
# Copyright (c) 2021 Ultimaker B.V.
# Uranium is released under the terms of the LGPLv3 or higher.
import os
from typing import Optional, List
from UM.FileHandler.FileHandler import FileHandler
from UM.Logger import Logger
from UM.OutputDevice import OutputDeviceError
from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice
from UM.Scene.SceneNode import SceneNode
from cura.API import Account
from cura.CuraApplication import CuraApplication
from .DigitalFactoryController import DigitalFactoryController
class DigitalFactoryOutputDevice(ProjectOutputDevice):
"""Implements an OutputDevice that supports saving to the digital factory library."""
def __init__(self, plugin_id, df_controller: DigitalFactoryController, add_to_output_devices: bool = False, parent = None) -> None:
super().__init__(device_id = "digital_factory", add_to_output_devices = add_to_output_devices, parent = parent)
self.setName("Digital Library") # Doesn't need to be translated
self.setShortDescription("Save to Library")
self.setDescription("Save to Library")
self.setIconName("save")
self.menu_entry_text = "To Digital Library"
self.shortcut = "Ctrl+Shift+S"
self._plugin_id = plugin_id
self._controller = df_controller
plugin_path = os.path.dirname(os.path.dirname(__file__))
self._dialog_path = os.path.join(plugin_path, "resources", "qml", "DigitalFactorySaveDialog.qml")
self._dialog = None
# Connect the write signals
self._controller.uploadStarted.connect(self._onWriteStarted)
self._controller.uploadFileProgress.connect(self.writeProgress.emit)
self._controller.uploadFileError.connect(self._onWriteError)
self._controller.uploadFileSuccess.connect(self.writeSuccess.emit)
self._controller.uploadFileFinished.connect(self._onWriteFinished)
self._priority = -1 # Negative value to ensure that it will have less priority than the LocalFileOutputDevice (which has 0)
self._application = CuraApplication.getInstance()
self._writing = False
self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account
self._account.loginStateChanged.connect(self._onLoginStateChanged)
self.enabled = self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess()
self._current_workspace_information = CuraApplication.getInstance().getCurrentWorkspaceInformation()
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs) -> None:
"""Request the specified nodes to be written.
Function called every time the 'To Digital Factory' option of the 'Save Project' submenu is triggered or when the
"Save to Library" action button is pressed (upon slicing).
:param nodes: A collection of scene nodes that should be written to the file.
:param file_name: A suggestion for the file name to write to.
:param limit_mimetypes: Limit the possible mimetypes to use for writing to these types.
:param file_handler: The handler responsible for reading and writing mesh files.
:param kwargs: Keyword arguments.
"""
if self._writing:
raise OutputDeviceError.DeviceBusyError()
self.loadWindow()
if self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess():
self._controller.nodes = nodes
df_workspace_information = self._current_workspace_information.getPluginMetadata("digital_factory")
self._controller.initialize(preselected_project_id = df_workspace_information.get("library_project_id"))
if not self._dialog:
Logger.log("e", "Unable to create the Digital Library Save dialog.")
return
self._dialog.show()
def loadWindow(self) -> None:
"""
Create the GUI window for the Digital Library Save dialog. If the window is already open, bring the focus on it.
"""
if self._dialog: # Dialogue is already open.
self._dialog.requestActivate() # Bring the focus on the dialogue.
return
if not self._controller.file_handlers:
self._controller.file_handlers = {
"3mf": CuraApplication.getInstance().getWorkspaceFileHandler(),
"ufp": CuraApplication.getInstance().getMeshFileHandler()
}
self._dialog = CuraApplication.getInstance().createQmlComponent(self._dialog_path, {"manager": self._controller})
if not self._dialog:
Logger.log("e", "Unable to create the Digital Library Save dialog.")
def _onLoginStateChanged(self, logged_in: bool) -> None:
"""
Sets the enabled status of the DigitalFactoryOutputDevice according to the account's login status
:param logged_in: The new login status
"""
self.enabled = logged_in and self._controller.userAccountHasLibraryAccess()
self.enabledChanged.emit()
def _onWriteStarted(self) -> None:
self._writing = True
self.writeStarted.emit(self)
def _onWriteFinished(self) -> None:
self._writing = False
self.writeFinished.emit(self)
def _onWriteError(self) -> None:
self._writing = False
self.writeError.emit(self)

View file

@ -0,0 +1,18 @@
# Copyright (c) 2021 Ultimaker B.V.
# Uranium is released under the terms of the LGPLv3 or higher.
from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
from .DigitalFactoryOutputDevice import DigitalFactoryOutputDevice
from .DigitalFactoryController import DigitalFactoryController
class DigitalFactoryOutputDevicePlugin(OutputDevicePlugin):
def __init__(self, df_controller: DigitalFactoryController) -> None:
super().__init__()
self.df_controller = df_controller
def start(self) -> None:
self.getOutputDeviceManager().addProjectOutputDevice(DigitalFactoryOutputDevice(plugin_id = self.getPluginId(), df_controller = self.df_controller, add_to_output_devices = True))
def stop(self) -> None:
self.getOutputDeviceManager().removeProjectOutputDevice("digital_factory")

View file

@ -0,0 +1,64 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import List, Optional
from PyQt5.QtCore import Qt, pyqtSignal
from UM.Logger import Logger
from UM.Qt.ListModel import ListModel
from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
PROJECT_UPDATED_AT_DATETIME_FORMAT = "%d-%m-%Y"
class DigitalFactoryProjectModel(ListModel):
DisplayNameRole = Qt.UserRole + 1
LibraryProjectIdRole = Qt.UserRole + 2
DescriptionRole = Qt.UserRole + 3
ThumbnailUrlRole = Qt.UserRole + 5
UsernameRole = Qt.UserRole + 6
LastUpdatedRole = Qt.UserRole + 7
dfProjectModelChanged = pyqtSignal()
def __init__(self, parent = None) -> None:
super().__init__(parent)
self.addRoleName(self.DisplayNameRole, "displayName")
self.addRoleName(self.LibraryProjectIdRole, "libraryProjectId")
self.addRoleName(self.DescriptionRole, "description")
self.addRoleName(self.ThumbnailUrlRole, "thumbnailUrl")
self.addRoleName(self.UsernameRole, "username")
self.addRoleName(self.LastUpdatedRole, "lastUpdated")
self._projects = [] # type: List[DigitalFactoryProjectResponse]
def setProjects(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
if self._projects == df_projects:
return
self._items.clear()
self._projects = df_projects
# self.sortProjectsBy("display_name")
self._update(df_projects)
def extendProjects(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
if not df_projects:
return
self._projects.extend(df_projects)
# self.sortProjectsBy("display_name")
self._update(df_projects)
def clearProjects(self) -> None:
self.clear()
self._projects.clear()
self.dfProjectModelChanged.emit()
def _update(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
for project in df_projects:
self.appendItem({
"displayName" : project.display_name,
"libraryProjectId" : project.library_project_id,
"description": project.description,
"thumbnailUrl": project.thumbnail_url,
"username": project.username,
"lastUpdated": project.last_updated.strftime(PROJECT_UPDATED_AT_DATETIME_FORMAT) if project.last_updated else "",
})
self.dfProjectModelChanged.emit()

View file

@ -0,0 +1,65 @@
# 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, List, Dict, Any
from .BaseModel import BaseModel
from .DigitalFactoryFileResponse import DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT
class DigitalFactoryProjectResponse(BaseModel):
"""Class representing a cloud project."""
def __init__(self,
library_project_id: str,
display_name: str,
username: str,
organization_shared: bool,
last_updated: Optional[str] = None,
created_at: Optional[str] = None,
thumbnail_url: Optional[str] = None,
organization_id: Optional[str] = None,
created_by_user_id: Optional[str] = None,
description: Optional[str] = "",
tags: Optional[List[str]] = None,
team_ids: Optional[List[str]] = None,
status: Optional[str] = None,
technical_requirements: Optional[Dict[str, Any]] = None,
**kwargs) -> None:
"""
Creates a new digital factory project response object
:param library_project_id:
:param display_name:
:param username:
:param organization_shared:
:param thumbnail_url:
:param created_by_user_id:
:param description:
:param tags:
:param kwargs:
"""
self.library_project_id = library_project_id
self.display_name = display_name
self.description = description
self.username = username
self.organization_shared = organization_shared
self.organization_id = organization_id
self.created_by_user_id = created_by_user_id
self.thumbnail_url = thumbnail_url
self.tags = tags
self.team_ids = team_ids
self.created_at = datetime.strptime(created_at, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) if created_at else None
self.last_updated = datetime.strptime(last_updated, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) if last_updated else None
self.status = status
self.technical_requirements = technical_requirements
super().__init__(**kwargs)
def __str__(self) -> str:
return "Project: {}, Id: {}, from: {}".format(self.display_name, self.library_project_id, self.username)
# Validates the model, raising an exception if the model is invalid.
def validate(self) -> None:
super().validate()
if not self.library_project_id:
raise ValueError("library_project_id is required on cloud project")

View file

@ -0,0 +1,55 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import io
from typing import List, Optional, Union
from UM.FileHandler.FileHandler import FileHandler
from UM.FileHandler.FileWriter import FileWriter
from UM.FileHandler.WriteFileJob import WriteFileJob
from UM.Logger import Logger
from UM.MimeTypeDatabase import MimeTypeDatabase
from UM.OutputDevice import OutputDeviceError
from UM.Scene.SceneNode import SceneNode
class ExportFileJob(WriteFileJob):
"""Job that exports the build plate to the correct file format for the Digital Factory Library project."""
def __init__(self, file_handler: FileHandler, nodes: List[SceneNode], job_name: str, extension: str) -> None:
file_types = file_handler.getSupportedFileTypesWrite()
if len(file_types) == 0:
Logger.log("e", "There are no file types available to write with!")
raise OutputDeviceError.WriteRequestFailedError("There are no file types available to write with!")
mode = None
file_writer = None
for file_type in file_types:
if file_type["extension"] == extension:
file_writer = file_handler.getWriter(file_type["id"])
mode = file_type.get("mode")
super().__init__(file_writer, self.createStream(mode = mode), nodes, mode)
# Determine the filename.
self.setFileName("{}.{}".format(job_name, extension))
def getOutput(self) -> bytes:
"""Get the job result as bytes as that is what we need to upload to the Digital Factory Library."""
output = self.getStream().getvalue()
if isinstance(output, str):
output = output.encode("utf-8")
return output
def getMimeType(self) -> str:
"""Get the mime type of the selected export file type."""
return MimeTypeDatabase.getMimeTypeForFile(self.getFileName()).name
@staticmethod
def createStream(mode) -> Union[io.BytesIO, io.StringIO]:
"""Creates the right kind of stream based on the preferred format."""
if mode == FileWriter.OutputMode.TextMode:
return io.StringIO()
else:
return io.BytesIO()

View file

@ -0,0 +1,30 @@
# Copyright (c) 2021 Ultimaker B.V.
from typing import Optional
class PaginationLinks:
"""Model containing pagination links."""
def __init__(self,
first: Optional[str] = None,
last: Optional[str] = None,
next: Optional[str] = None,
prev: Optional[str] = None,
**kwargs) -> None:
"""
Creates a new digital factory project response object
:param first: The URL for the first page.
:param last: The URL for the last page.
:param next: The URL for the next page.
:param prev: The URL for the prev page.
:param kwargs:
"""
self.first_page = first
self.last_page = last
self.next_page = next
self.prev_page = prev
def __str__(self) -> str:
return "Pagination Links | First: {}, Last: {}, Next: {}, Prev: {}".format(self.first_page, self.last_page, self.next_page, self.prev_page)

View file

@ -0,0 +1,43 @@
# Copyright (c) 2021 Ultimaker B.V.
from typing import Optional, Dict, Any
from .PaginationLinks import PaginationLinks
from .PaginationMetadata import PaginationMetadata
from .ResponseMeta import ResponseMeta
class PaginationManager:
def __init__(self, limit: int) -> None:
self.limit = limit # The limit of items per page
self.meta = None # type: Optional[ResponseMeta] # The metadata of the paginated response
self.links = None # type: Optional[PaginationLinks] # The pagination-related links
def setResponseMeta(self, meta: Optional[Dict[str, Any]]) -> None:
self.meta = None
if meta:
page = None
if "page" in meta:
page = PaginationMetadata(**meta["page"])
self.meta = ResponseMeta(page)
def setLinks(self, links: Optional[Dict[str, str]]) -> None:
self.links = PaginationLinks(**links) if links else None
def setLimit(self, new_limit: int) -> None:
"""
Sets the limit of items per page.
:param new_limit: The new limit of items per page
"""
self.limit = new_limit
self.reset()
def reset(self) -> None:
"""
Sets the metadata and links to None.
"""
self.meta = None
self.links = None

View file

@ -0,0 +1,25 @@
# Copyright (c) 2021 Ultimaker B.V.
from typing import Optional
class PaginationMetadata:
"""Class representing the metadata related to pagination."""
def __init__(self,
total_count: Optional[int] = None,
total_pages: Optional[int] = None,
**kwargs) -> None:
"""
Creates a new digital factory project response object
:param total_count: The total count of items.
:param total_pages: The total number of pages when pagination is applied.
:param kwargs:
"""
self.total_count = total_count
self.total_pages = total_pages
self.__dict__.update(kwargs)
def __str__(self) -> str:
return "PaginationMetadata | Total Count: {}, Total Pages: {}".format(self.total_count, self.total_pages)

View file

@ -0,0 +1,24 @@
# Copyright (c) 2021 Ultimaker B.V.
from typing import Optional
from .PaginationMetadata import PaginationMetadata
class ResponseMeta:
"""Class representing the metadata included in a Digital Library response (if any)"""
def __init__(self,
page: Optional[PaginationMetadata] = None,
**kwargs) -> None:
"""
Creates a new digital factory project response object
:param page: Metadata related to pagination
:param kwargs:
"""
self.page = page
self.__dict__.update(kwargs)
def __str__(self) -> str:
return "Response Meta | {}".format(self.page)

View file

View file

@ -0,0 +1,48 @@
from unittest.mock import MagicMock, patch
import pytest
from src.DFFileExportAndUploadManager import DFFileExportAndUploadManager
@pytest.fixture
def upload_manager():
file_handler = MagicMock(name = "file_handler")
file_handler.getSupportedFileTypesWrite = MagicMock(return_value = [{
"id": "test",
"extension": ".3mf",
"description": "nope",
"mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
"mode": "binary",
"hide_in_file_dialog": True,
}])
node = MagicMock(name = "SceneNode")
application = MagicMock(name = "CuraApplication")
with patch("cura.CuraApplication.CuraApplication.getInstance", MagicMock(return_value = application)):
return DFFileExportAndUploadManager(file_handlers = {"3mf": file_handler},
nodes = [node],
library_project_id = "test_library_project_id",
library_project_name = "test_library_project_name",
file_name = "file_name",
formats = ["3mf"],
on_upload_error = MagicMock(),
on_upload_success = MagicMock(),
on_upload_finished = MagicMock(),
on_upload_progress = MagicMock())
@pytest.mark.parametrize("input,expected_result",
[("", ""),
("invalid json! {}", ""),
("{\"errors\": [{}]}", ""),
("{\"errors\": [{\"title\": \"some title\"}]}", "some title")])
def test_extractErrorTitle(upload_manager, input, expected_result):
assert upload_manager.extractErrorTitle(input) == expected_result
def test_exportJobError(upload_manager):
mocked_application = MagicMock()
with patch("UM.Application.Application.getInstance", MagicMock(return_value = mocked_application)):
upload_manager._onJobExportError("file_name.3mf")
# Ensure that message was displayed
mocked_application.showMessageSignal.emit.assert_called_once()

View file

@ -0,0 +1,73 @@
from pathlib import Path
from src.DigitalFactoryFileModel import DigitalFactoryFileModel
from src.DigitalFactoryFileResponse import DigitalFactoryFileResponse
file_1 = DigitalFactoryFileResponse(client_id = "client_id_1",
content_type = "zomg",
file_name = "file_1.3mf",
file_id = "file_id_1",
library_project_id = "project_id_1",
status = "test",
user_id = "user_id_1",
username = "username_1",
uploaded_at = "2021-04-07T10:33:25.000Z")
file_2 = DigitalFactoryFileResponse(client_id ="client_id_2",
content_type = "zomg",
file_name = "file_2.3mf",
file_id = "file_id_2",
library_project_id = "project_id_2",
status = "test",
user_id = "user_id_2",
username = "username_2",
uploaded_at = "2021-02-06T09:33:22.000Z")
file_wtf = DigitalFactoryFileResponse(client_id ="client_id_1",
content_type = "zomg",
file_name = "file_3.wtf",
file_id = "file_id_3",
library_project_id = "project_id_1",
status = "test",
user_id = "user_id_1",
username = "username_1",
uploaded_at = "2021-04-06T12:33:25.000Z")
def test_setFiles():
model = DigitalFactoryFileModel()
assert model.count == 0
model.setFiles([file_1, file_2])
assert model.count == 2
assert model.getItem(0)["fileName"] == "file_1.3mf"
assert model.getItem(1)["fileName"] == "file_2.3mf"
def test_clearProjects():
model = DigitalFactoryFileModel()
model.setFiles([file_1, file_2])
model.clearFiles()
assert model.count == 0
def test_setProjectMultipleTimes():
model = DigitalFactoryFileModel()
model.setFiles([file_1, file_2])
model.setFiles([file_2])
assert model.count == 1
assert model.getItem(0)["fileName"] == "file_2.3mf"
def test_setFilter():
model = DigitalFactoryFileModel()
model.setFiles([file_1, file_2, file_wtf])
model.setFilters({"file_name": lambda x: Path(x).suffix[1:].lower() in ["3mf"]})
assert model.count == 2
model.clearFilters()
assert model.count == 3

View file

@ -0,0 +1,55 @@
from src.DigitalFactoryProjectModel import DigitalFactoryProjectModel
from src.DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
project_1 = DigitalFactoryProjectResponse(library_project_id = "omg",
display_name = "zomg",
username = "nope",
organization_shared = True)
project_2 = DigitalFactoryProjectResponse(library_project_id = "omg2",
display_name = "zomg2",
username = "nope",
organization_shared = False)
def test_setProjects():
model = DigitalFactoryProjectModel()
assert model.count == 0
model.setProjects([project_1, project_2])
assert model.count == 2
assert model.getItem(0)["displayName"] == "zomg"
assert model.getItem(1)["displayName"] == "zomg2"
def test_clearProjects():
model = DigitalFactoryProjectModel()
model.setProjects([project_1, project_2])
model.clearProjects()
assert model.count == 0
def test_setProjectMultipleTimes():
model = DigitalFactoryProjectModel()
model.setProjects([project_1, project_2])
model.setProjects([project_2])
assert model.count == 1
assert model.getItem(0)["displayName"] == "zomg2"
def test_extendProjects():
model = DigitalFactoryProjectModel()
assert model.count == 0
model.setProjects([project_1])
assert model.count == 1
model.extendProjects([project_2])
assert model.count == 2
assert model.getItem(0)["displayName"] == "zomg"
assert model.getItem(1)["displayName"] == "zomg2"

View file

@ -0,0 +1,86 @@
from unittest.mock import MagicMock
import pytest
from cura.CuraApplication import CuraApplication
from src.DigitalFactoryApiClient import DigitalFactoryApiClient
from src.PaginationManager import PaginationManager
@pytest.fixture
def application():
app = MagicMock(spec=CuraApplication, name = "Mocked Cura Application")
return app
@pytest.fixture
def pagination_manager():
manager = MagicMock(name = "Mocked Pagination Manager")
return manager
@pytest.fixture
def api_client(application, pagination_manager):
api_client = DigitalFactoryApiClient(application, MagicMock())
api_client._projects_pagination_mgr = pagination_manager
return api_client
def test_getProjectsFirstPage(api_client):
# setup
http_manager = MagicMock()
api_client._http = http_manager
pagination_manager = api_client._projects_pagination_mgr
pagination_manager.limit = 20
finished_callback = MagicMock()
failed_callback = MagicMock()
# Call
api_client.getProjectsFirstPage(on_finished = finished_callback, failed = failed_callback)
# Asserts
pagination_manager.reset.assert_called_once() # Should be called since we asked for new set of projects
http_manager.get.assert_called_once()
args = http_manager.get.call_args_list[0]
# Ensure that it's called with the right limit
assert args[0][0] == "https://api.ultimaker.com/cura/v1/projects?limit=20"
# Change the limit & try again
http_manager.get.reset_mock()
pagination_manager.limit = 80
api_client.getProjectsFirstPage(on_finished = finished_callback, failed = failed_callback)
args = http_manager.get.call_args_list[0]
# Ensure that it's called with the right limit
assert args[0][0] == "https://api.ultimaker.com/cura/v1/projects?limit=80"
def test_getMoreProjects_noNewProjects(api_client):
api_client.hasMoreProjectsToLoad = MagicMock(return_value = False)
http_manager = MagicMock()
api_client._http = http_manager
finished_callback = MagicMock()
failed_callback = MagicMock()
api_client.getMoreProjects(finished_callback, failed_callback)
http_manager.get.assert_not_called()
def test_getMoreProjects_hasNewProjects(api_client):
api_client.hasMoreProjectsToLoad = MagicMock(return_value = True)
http_manager = MagicMock()
api_client._http = http_manager
finished_callback = MagicMock()
failed_callback = MagicMock()
api_client.getMoreProjects(finished_callback, failed_callback)
http_manager.get.assert_called_once()
def test_clear(api_client):
api_client.clear()
api_client._projects_pagination_mgr.reset.assert_called_once()

View file

@ -0,0 +1,5 @@
# Ensure that the importing for all tests work
import sys
import os
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Checks for firmware updates.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a machine actions for updating firmware.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Reads g-code from a compressed archive.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Writes g-code to a compressed archive.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for importing profiles from g-code files.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Victor Larchenko, Ultimaker B.V.",
"version": "1.0.1",
"description": "Allows loading and displaying G-code files.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Writes g-code to a file.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Enables ability to generate printable geometry from 2D image files.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for importing profiles from legacy Cura versions.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -27,7 +27,7 @@ Cura.MachineAction
Connections
{
target: extrudersModel
onItemsChanged: tabNameModel.update()
function onItemsChanged() { tabNameModel.update() }
}
ListModel

View file

@ -326,7 +326,7 @@ Item
Connections
{
target: Cura.MachineManager
onGlobalContainerChanged: extruderCountModel.update()
function onGlobalContainerChanged() { extruderCountModel.update() }
}
}

View file

@ -3,6 +3,6 @@
"author": "fieldOfView, Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a way to change machine settings (such as build volume, nozzle size, etc.).",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -2,7 +2,7 @@
"name": "Model Checker",
"author": "Ultimaker B.V.",
"version": "1.0.1",
"api": "7.4.0",
"api": "7.5.0",
"description": "Checks models and print configuration for possible printing issues and give suggestions.",
"i18n-catalog": "cura"
}

View file

@ -137,7 +137,7 @@ Rectangle
id: externalLinkIcon
anchors.verticalCenter: parent.verticalCenter
color: UM.Theme.getColor("text_link")
source: UM.Theme.getIcon("external_link")
source: UM.Theme.getIcon("LinkExternal")
width: UM.Theme.getSize("monitor_external_link_icon").width
height: UM.Theme.getSize("monitor_external_link_icon").height
}
@ -164,5 +164,16 @@ Rectangle
onExited: manageQueueText.font.underline = false
}
}
Label
{
id: noConnectionLabel
anchors.horizontalCenter: parent.horizontalCenter
visible: !isNetworkConfigurable
text: catalog.i18nc("@info", "In order to monitor your print from Cura, please connect the printer.")
font: UM.Theme.getFont("medium")
color: UM.Theme.getColor("text")
wrapMode: Text.WordWrap
width: contentWidth
}
}
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a monitor stage in Cura.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -24,7 +24,7 @@ Button {
anchors.verticalCenter: parent.verticalCenter
height: (label.height / 2) | 0
width: height
source: control.checked ? UM.Theme.getIcon("arrow_bottom") : UM.Theme.getIcon("arrow_right");
source: control.checked ? UM.Theme.getIcon("ChevronSingleDown") : UM.Theme.getIcon("ChevronSingleRight");
color: control.hovered ? palette.highlight : palette.buttonText
}
UM.RecolorImage

View file

@ -35,7 +35,7 @@ UM.TooltipArea
Connections
{
target: addedSettingsModel
onVisibleCountChanged:
function onVisibleCountChanged()
{
check.checked = addedSettingsModel.getVisible(model.key)
}

View file

@ -80,7 +80,7 @@ Item
{
id: normalButton
text: catalog.i18nc("@label", "Normal model")
iconSource: UM.Theme.getIcon("pos_normal");
iconSource: UM.Theme.getIcon("Infill0");
property bool needBorder: true
checkable: true
onClicked: setMeshType(normalMeshType);
@ -92,7 +92,7 @@ Item
{
id: supportMeshButton
text: catalog.i18nc("@label", "Print as support")
iconSource: UM.Theme.getIcon("pos_print_as_support");
iconSource: UM.Theme.getIcon("MeshTypeSupport");
property bool needBorder: true
checkable:true
onClicked: setMeshType(supportMeshType)
@ -104,7 +104,7 @@ Item
{
id: overlapMeshButton
text: catalog.i18nc("@label", "Modify settings for overlaps")
iconSource: UM.Theme.getIcon("pos_modify_overlaps");
iconSource: UM.Theme.getIcon("MeshTypeIntersect");
property bool needBorder: true
checkable:true
onClicked: setMeshType(infillMeshType)
@ -116,7 +116,7 @@ Item
{
id: antiOverhangMeshButton
text: catalog.i18nc("@label", "Don't support overlaps")
iconSource: UM.Theme.getIcon("pos_modify_dont_support_overlap");
iconSource: UM.Theme.getIcon("BlockSupportOverlaps");
property bool needBorder: true
checkable: true
onClicked: setMeshType(antiOverhangMeshType)
@ -304,7 +304,7 @@ Item
height: width
sourceSize.height: width
color: control.hovered ? UM.Theme.getColor("setting_control_button_hover") : UM.Theme.getColor("setting_control_button")
source: UM.Theme.getIcon("minus")
source: UM.Theme.getIcon("Minus")
}
}
}
@ -334,13 +334,13 @@ Item
Connections
{
target: inheritStackProvider
onPropertiesChanged: provider.forcePropertiesChanged()
function onPropertiesChanged() { provider.forcePropertiesChanged() }
}
Connections
{
target: UM.ActiveTool
onPropertiesChanged:
function onPropertiesChanged()
{
// the values cannot be bound with UM.ActiveTool.properties.getValue() calls,
// so here we connect to the signal and update the those values.

View file

@ -13,7 +13,7 @@ def getMetaData():
"tool": {
"name": i18n_catalog.i18nc("@label", "Per Model Settings"),
"description": i18n_catalog.i18nc("@info:tooltip", "Configure Per Model Settings"),
"icon": "tool_icon.svg",
"icon": "MeshType",
"tool_panel": "PerObjectSettingsPanel.qml",
"weight": 3
},

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides the Per Model Settings.",
"api": "7.4.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="30px" height="30px" viewBox="0 0 28 28" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 57.1 (83088) - https://sketch.com -->
<title>per_model_settings</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M9.73076923,0 L9.226,1.345 L0.449,11 L0,11 L0.639,9.084 L8.896,0 L9.73076923,0 Z M8.49,3.472 L8.907,4.721 L3.199,11 L1.647,11 L8.49,3.472 Z M9.228,5.685 L9.645,6.935 L5.949,11 L4.397,11 L9.228,5.685 Z M9.966,7.899 L10.382,9.148 L8.699,11 L7.147,11 L9.966,7.899 Z M10.704,10.112 L11,11 L9.896,11 L10.704,10.112 Z M7.698,0 L1.332,7.004 L2.23,4.308 L6.146,0 L7.698,0 Z M4.948,0 L2.344,2.866 L1.89,1.656 L3.396,0 L4.948,0 Z M2.198,0 L1.54,0.724 L1.26923077,0 L2.198,0 Z" id="path-1"></path>
</defs>
<g id="per_model_settings" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Per-model" transform="translate(2.000000, 2.000000)">
<polygon id="Path-Copy-5" fill="#000" points="1.26923077 0 9.73076923 0 8.46153846 3.38461538 11 11 0 11 2.53846154 3.38461538"></polygon>
<polygon id="Path-Copy-8" fill="#000" points="14.2692308 13 22.7307692 13 21.4615385 16.3846154 24 24 13 24 15.5384615 16.3846154"></polygon>
<g id="stripe" transform="translate(13.000000, 0.000000)">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use id="Combined-Shape" fill="#000" xlink:href="#path-1"></use>
</g>
<path d="M1.990731,13.5 L3.06878027,16.374798 L0.693712943,23.5 L10.3062871,23.5 L7.93121973,16.374798 L9.009269,13.5 L1.990731,13.5 Z" id="Path-Copy-7" stroke="#000"></path>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

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