mirror of
https://github.com/Ultimaker/Cura.git
synced 2025-07-06 22:47:29 -06:00
Merge branch 'master' into graphics_buffer_update
This commit is contained in:
commit
458fbd35f1
4327 changed files with 77905 additions and 22116 deletions
2
.github/ISSUE_TEMPLATE.md
vendored
2
.github/ISSUE_TEMPLATE.md
vendored
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
name: Bug report
|
name: Old Bug report
|
||||||
about: Create a report to help us fix issues.
|
about: Create a report to help us fix issues.
|
||||||
title: ''
|
title: ''
|
||||||
labels: 'Type: Bug'
|
labels: 'Type: Bug'
|
||||||
|
|
26
.github/ISSUE_TEMPLATE/bugreport.yaml
vendored
26
.github/ISSUE_TEMPLATE/bugreport.yaml
vendored
|
@ -1,7 +1,6 @@
|
||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Create a report to help us fix issues.
|
description: Create a report to help us fix issues.
|
||||||
labels: "Type: Bug"
|
labels: "Type: Bug"
|
||||||
issue_body: true
|
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
|
@ -15,7 +14,7 @@ body:
|
||||||
attributes:
|
attributes:
|
||||||
label: Application Version
|
label: Application Version
|
||||||
description: The version of Cura this issue occurs with.
|
description: The version of Cura this issue occurs with.
|
||||||
placeholder: 4.8.0
|
placeholder: 4.9.0
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
|
@ -56,13 +55,28 @@ body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
## Additional information & file uploads
|
|
||||||
|
|
||||||
Please be sure to add the following files:
|
Please be sure to add the following files:
|
||||||
* For slicing issues, upload a **project file** that clearly shows the bug.
|
* 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.
|
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!
|
G-code files are not project files!
|
||||||
* **Screenshots** of showing the problem, perhaps before/after images.
|
* **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.
|
* 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
|
||||||
|
|
||||||
You can add these files and additional information that is relevant to the issue in the comments below.
|
|
||||||
|
|
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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!
|
10
.github/ISSUE_TEMPLATE/featurerequest.yaml
vendored
10
.github/ISSUE_TEMPLATE/featurerequest.yaml
vendored
|
@ -1,7 +1,6 @@
|
||||||
name: Feature Request
|
name: Feature Request
|
||||||
description: Suggest an idea for this project.
|
description: Suggest an idea for this project.
|
||||||
labels: "Type: New Feature"
|
labels: "Type: New Feature"
|
||||||
issue_body: true
|
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
|
@ -28,7 +27,7 @@ body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Describe alternatives you've considered
|
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...
|
placeholder: The alternatives I've considered are...
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
@ -39,8 +38,7 @@ body:
|
||||||
placeholder: It will affect...
|
placeholder: It will affect...
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: markdown
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
label: Additional information & file uploads
|
||||||
## Additional information & file uploads
|
description: You can add pictures or files to visualize your feature request in the comments below.
|
||||||
You can add pictures or files to visualize your feature request in the comments below.
|
|
14
README.md
14
README.md
|
@ -10,13 +10,13 @@ For crashes and similar issues, please attach the following information:
|
||||||
|
|
||||||
* (On Windows) The log as produced by dxdiag (start -> run -> dxdiag -> save output)
|
* (On Windows) The log as produced by dxdiag (start -> run -> dxdiag -> save output)
|
||||||
* The Cura GUI log file, located at
|
* The Cura GUI log file, located at
|
||||||
* `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
|
* `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
|
||||||
* `$USER/Library/Application Support/cura/<Cura version>/cura.log` (OSX)
|
* `$HOME/Library/Application Support/cura/<Cura version>/cura.log` (OSX)
|
||||||
* `$USER/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux)
|
* `$HOME/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux)
|
||||||
|
|
||||||
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
|
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
|
||||||
|
|
||||||
For additional support, you could also ask in the #cura channel on FreeNode IRC. For help with development, there is also the #cura-dev channel.
|
For additional support, you could also ask in the [#cura channel](https://web.libera.chat/#cura) on [libera.chat](https://libera.chat/). For help with development, there is also the [#cura-dev channel](https://web.libera.chat/#cura-dev).
|
||||||
|
|
||||||
Dependencies
|
Dependencies
|
||||||
------------
|
------------
|
||||||
|
@ -26,10 +26,16 @@ Dependencies
|
||||||
* [PySerial](https://github.com/pyserial/pyserial) Only required for USB printing support.
|
* [PySerial](https://github.com/pyserial/pyserial) Only required for USB printing support.
|
||||||
* [python-zeroconf](https://github.com/jstasiak/python-zeroconf) Only required to detect mDNS-enabled printers.
|
* [python-zeroconf](https://github.com/jstasiak/python-zeroconf) Only required to detect mDNS-enabled printers.
|
||||||
|
|
||||||
|
For a list of required Python packages, with their recommended version, see `requirements.txt`.
|
||||||
|
|
||||||
|
This list is not exhaustive at the moment, please check the links in the next section for more details.
|
||||||
|
|
||||||
Build scripts
|
Build scripts
|
||||||
-------------
|
-------------
|
||||||
Please check out [cura-build](https://github.com/Ultimaker/cura-build) for detailed building instructions.
|
Please check out [cura-build](https://github.com/Ultimaker/cura-build) for detailed building instructions.
|
||||||
|
|
||||||
|
If you want to build the entire environment from scratch before building Cura as well, [cura-build-environment](https://github.com/Ultimaker/cura-build) might be a starting point before cura-build. (Again, see cura-build for more details.)
|
||||||
|
|
||||||
Running from Source
|
Running from Source
|
||||||
-------------
|
-------------
|
||||||
Please check our [Wiki page](https://github.com/Ultimaker/Cura/wiki/Running-Cura-from-Source) for details about running Cura from source.
|
Please check our [Wiki page](https://github.com/Ultimaker/Cura/wiki/Running-Cura-from-Source) for details about running Cura from source.
|
||||||
|
|
|
@ -40,7 +40,7 @@ class Account(QObject):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# The interval in which sync services are automatically triggered
|
# The interval in which sync services are automatically triggered
|
||||||
SYNC_INTERVAL = 30.0 # seconds
|
SYNC_INTERVAL = 60.0 # seconds
|
||||||
Q_ENUMS(SyncState)
|
Q_ENUMS(SyncState)
|
||||||
|
|
||||||
loginStateChanged = pyqtSignal(bool)
|
loginStateChanged = pyqtSignal(bool)
|
||||||
|
@ -58,6 +58,11 @@ class Account(QObject):
|
||||||
manualSyncEnabledChanged = pyqtSignal(bool)
|
manualSyncEnabledChanged = pyqtSignal(bool)
|
||||||
updatePackagesEnabledChanged = 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:
|
def __init__(self, application: "CuraApplication", parent = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._application = application
|
self._application = application
|
||||||
|
@ -79,10 +84,7 @@ class Account(QObject):
|
||||||
CALLBACK_PORT=self._callback_port,
|
CALLBACK_PORT=self._callback_port,
|
||||||
CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port),
|
CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port),
|
||||||
CLIENT_ID="um----------------------------ultimaker_cura",
|
CLIENT_ID="um----------------------------ultimaker_cura",
|
||||||
CLIENT_SCOPES="account.user.read drive.backup.read drive.backup.write packages.download "
|
CLIENT_SCOPES=self.CLIENT_SCOPES,
|
||||||
"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",
|
|
||||||
AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data",
|
AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data",
|
||||||
AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root),
|
AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root),
|
||||||
AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root)
|
AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root)
|
||||||
|
@ -107,7 +109,6 @@ class Account(QObject):
|
||||||
self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged)
|
self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged)
|
||||||
self._authorization_service.loadAuthDataFromPreferences()
|
self._authorization_service.loadAuthDataFromPreferences()
|
||||||
|
|
||||||
|
|
||||||
@pyqtProperty(int, notify=syncStateChanged)
|
@pyqtProperty(int, notify=syncStateChanged)
|
||||||
def syncState(self):
|
def syncState(self):
|
||||||
return self._sync_state
|
return self._sync_state
|
||||||
|
@ -176,6 +177,7 @@ class Account(QObject):
|
||||||
if error_message:
|
if error_message:
|
||||||
if self._error_message:
|
if self._error_message:
|
||||||
self._error_message.hide()
|
self._error_message.hide()
|
||||||
|
Logger.log("w", "Failed to login: %s", error_message)
|
||||||
self._error_message = Message(error_message, title = i18n_catalog.i18nc("@info:title", "Login failed"))
|
self._error_message = Message(error_message, title = i18n_catalog.i18nc("@info:title", "Login failed"))
|
||||||
self._error_message.show()
|
self._error_message.show()
|
||||||
self._logged_in = False
|
self._logged_in = False
|
||||||
|
|
|
@ -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
|
# 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
|
# 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.
|
# CuraVersion.py.in template.
|
||||||
CuraSDKVersion = "7.4.0"
|
CuraSDKVersion = "7.6.0"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from cura.CuraVersion import CuraAppName # type: ignore
|
from cura.CuraVersion import CuraAppName # type: ignore
|
||||||
|
|
|
@ -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
|
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
|
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_width = build_volume.getWidth()
|
||||||
machine_depth = build_volume.getDepth()
|
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)
|
# Clip the disallowed areas so that they don't overlap the bounding box (The arranger chokes otherwise)
|
||||||
clipped_area = area.intersectionConvexHulls(build_plate_polygon)
|
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():
|
for point in clipped_area.getPoints():
|
||||||
converted_points.append(Point(int(point[0] * factor), int(point[1] * factor)))
|
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 = []
|
converted_points = []
|
||||||
hull_polygon = node.callDecoration("getConvexHull")
|
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():
|
for point in hull_polygon.getPoints():
|
||||||
converted_points.append(Point(point[0] * factor, point[1] * factor))
|
converted_points.append(Point(point[0] * factor, point[1] * factor))
|
||||||
item = Item(converted_points)
|
item = Item(converted_points)
|
||||||
|
@ -99,7 +100,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
|
||||||
config = NfpConfig()
|
config = NfpConfig()
|
||||||
config.accuracy = 1.0
|
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.
|
# 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))
|
node_items = list(filter(lambda item: not item.isFixed(), node_items))
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
import copy
|
import copy
|
||||||
from typing import Optional, Tuple, TYPE_CHECKING
|
from typing import Optional, Tuple, TYPE_CHECKING, Union
|
||||||
|
|
||||||
from UM.Math.Polygon import Polygon
|
from UM.Math.Polygon import Polygon
|
||||||
|
|
||||||
|
@ -14,14 +14,14 @@ if TYPE_CHECKING:
|
||||||
class ShapeArray:
|
class ShapeArray:
|
||||||
"""Polygon representation as an array for use with :py:class:`cura.Arranging.Arrange.Arrange`"""
|
"""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.arr = arr
|
||||||
self.offset_x = offset_x
|
self.offset_x = offset_x
|
||||||
self.offset_y = offset_y
|
self.offset_y = offset_y
|
||||||
self.scale = scale
|
self.scale = scale
|
||||||
|
|
||||||
@classmethod
|
@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
|
"""Instantiate from a bunch of vertices
|
||||||
|
|
||||||
:param vertices:
|
:param vertices:
|
||||||
|
@ -98,7 +98,7 @@ class ShapeArray:
|
||||||
return offset_shape_arr, hull_shape_arr
|
return offset_shape_arr, hull_shape_arr
|
||||||
|
|
||||||
@classmethod
|
@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
|
"""Create :py:class:`numpy.ndarray` with dimensions defined by shape
|
||||||
|
|
||||||
Fills polygon defined by vertices with ones, all other values zero
|
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
|
: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
|
fill = numpy.ones(base_array.shape) * True # Initialize boolean array defining shape fill
|
||||||
|
|
||||||
|
@ -126,7 +126,7 @@ class ShapeArray:
|
||||||
return base_array
|
return base_array
|
||||||
|
|
||||||
@classmethod
|
@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
|
"""Return indices that mark one side of the line, used by arrayFromPolygon
|
||||||
|
|
||||||
Uses the line defined by p1 and p2 to check array of
|
Uses the line defined by p1 and p2 to check array of
|
||||||
|
|
|
@ -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.
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
from PyQt5.QtCore import QTimer
|
from PyQt5.QtCore import QTimer
|
||||||
|
@ -6,6 +6,8 @@ from typing import Any, TYPE_CHECKING
|
||||||
|
|
||||||
from UM.Logger import Logger
|
from UM.Logger import Logger
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from cura.CuraApplication import CuraApplication
|
from cura.CuraApplication import CuraApplication
|
||||||
|
|
||||||
|
@ -56,8 +58,8 @@ class AutoSave:
|
||||||
|
|
||||||
def _onTimeout(self) -> None:
|
def _onTimeout(self) -> None:
|
||||||
self._saving = True # To prevent the save process from triggering another autosave.
|
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()
|
self._application.saveSettings()
|
||||||
|
Logger.log("d", "Autosaving preferences, instances and profiles took %s seconds", time.time() - save_start_time)
|
||||||
self._saving = False
|
self._saving = False
|
||||||
|
|
|
@ -5,14 +5,16 @@ import io
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
from copy import deepcopy
|
||||||
from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile
|
from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile
|
||||||
from typing import Dict, Optional, TYPE_CHECKING
|
from typing import Dict, Optional, TYPE_CHECKING, List
|
||||||
|
|
||||||
from UM import i18nCatalog
|
from UM import i18nCatalog
|
||||||
from UM.Logger import Logger
|
from UM.Logger import Logger
|
||||||
from UM.Message import Message
|
from UM.Message import Message
|
||||||
from UM.Platform import Platform
|
from UM.Platform import Platform
|
||||||
from UM.Resources import Resources
|
from UM.Resources import Resources
|
||||||
|
from UM.Version import Version
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from cura.CuraApplication import CuraApplication
|
from cura.CuraApplication import CuraApplication
|
||||||
|
@ -27,6 +29,11 @@ class Backup:
|
||||||
IGNORED_FILES = [r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc"]
|
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."""
|
"""These files should be ignored when making a backup."""
|
||||||
|
|
||||||
|
IGNORED_FOLDERS = [] # type: List[str]
|
||||||
|
|
||||||
|
SECRETS_SETTINGS = ["general/ultimaker_auth_data"]
|
||||||
|
"""Secret preferences that need to obfuscated when making a backup of Cura"""
|
||||||
|
|
||||||
catalog = i18nCatalog("cura")
|
catalog = i18nCatalog("cura")
|
||||||
"""Re-use translation catalog"""
|
"""Re-use translation catalog"""
|
||||||
|
|
||||||
|
@ -43,6 +50,9 @@ class Backup:
|
||||||
|
|
||||||
Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir)
|
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.
|
# Ensure all current settings are saved.
|
||||||
self._application.saveSettings()
|
self._application.saveSettings()
|
||||||
|
|
||||||
|
@ -67,8 +77,9 @@ class Backup:
|
||||||
machine_count = max(len([s for s in files if "machine_instances/" in s]) - 1, 0) # If people delete their profiles but not their preferences, it can still make a backup, and report -1 profiles. Server crashes on this.
|
machine_count = max(len([s for s in files if "machine_instances/" in s]) - 1, 0) # If people delete their profiles but not their preferences, it can still make a backup, and report -1 profiles. Server crashes on this.
|
||||||
material_count = max(len([s for s in files if "materials/" in s]) - 1, 0)
|
material_count = max(len([s for s in files if "materials/" in s]) - 1, 0)
|
||||||
profile_count = max(len([s for s in files if "quality_changes/" in s]) - 1, 0)
|
profile_count = max(len([s for s in files if "quality_changes/" in s]) - 1, 0)
|
||||||
plugin_count = len([s for s in files if "plugin.json" in s])
|
# We don't store plugins anymore, since if you can make backups, you have an account (and the plugins are
|
||||||
|
# on the marketplace anyway)
|
||||||
|
plugin_count = 0
|
||||||
# Store the archive and metadata so the BackupManager can fetch them when needed.
|
# Store the archive and metadata so the BackupManager can fetch them when needed.
|
||||||
self.zip_file = buffer.getvalue()
|
self.zip_file = buffer.getvalue()
|
||||||
self.meta_data = {
|
self.meta_data = {
|
||||||
|
@ -78,6 +89,8 @@ class Backup:
|
||||||
"profile_count": str(profile_count),
|
"profile_count": str(profile_count),
|
||||||
"plugin_count": str(plugin_count)
|
"plugin_count": str(plugin_count)
|
||||||
}
|
}
|
||||||
|
# Restore the obfuscated settings
|
||||||
|
self._illuminate(**secrets)
|
||||||
|
|
||||||
def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]:
|
def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]:
|
||||||
"""Make a full archive from the given root path with the given name.
|
"""Make a full archive from the given root path with the given name.
|
||||||
|
@ -85,8 +98,7 @@ class Backup:
|
||||||
:param root_path: The root directory to archive recursively.
|
:param root_path: The root directory to archive recursively.
|
||||||
:return: The archive as bytes.
|
:return: The archive as bytes.
|
||||||
"""
|
"""
|
||||||
|
ignore_string = re.compile("|".join(self.IGNORED_FILES + self.IGNORED_FOLDERS))
|
||||||
ignore_string = re.compile("|".join(self.IGNORED_FILES))
|
|
||||||
try:
|
try:
|
||||||
archive = ZipFile(buffer, "w", ZIP_DEFLATED)
|
archive = ZipFile(buffer, "w", ZIP_DEFLATED)
|
||||||
for root, folders, files in os.walk(root_path):
|
for root, folders, files in os.walk(root_path):
|
||||||
|
@ -123,8 +135,8 @@ class Backup:
|
||||||
"Tried to restore a Cura backup without having proper data or meta data."))
|
"Tried to restore a Cura backup without having proper data or meta data."))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
current_version = self._application.getVersion()
|
current_version = Version(self._application.getVersion())
|
||||||
version_to_restore = self.meta_data.get("cura_release", "master")
|
version_to_restore = Version(self.meta_data.get("cura_release", "master"))
|
||||||
|
|
||||||
if current_version < version_to_restore:
|
if current_version < version_to_restore:
|
||||||
# Cannot restore version newer than current because settings might have changed.
|
# Cannot restore version newer than current because settings might have changed.
|
||||||
|
@ -134,8 +146,16 @@ class Backup:
|
||||||
"Tried to restore a Cura backup that is higher than the current version."))
|
"Tried to restore a Cura backup that is higher than the current version."))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Get the current secrets and store since the back-up doesn't contain those
|
||||||
|
secrets = self._obfuscate()
|
||||||
|
|
||||||
version_data_dir = Resources.getDataStoragePath()
|
version_data_dir = Resources.getDataStoragePath()
|
||||||
|
try:
|
||||||
archive = ZipFile(io.BytesIO(self.zip_file), "r")
|
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)
|
extracted = self._extractArchive(archive, version_data_dir)
|
||||||
|
|
||||||
# Under Linux, preferences are stored elsewhere, so we copy the file to there.
|
# Under Linux, preferences are stored elsewhere, so we copy the file to there.
|
||||||
|
@ -146,6 +166,12 @@ class Backup:
|
||||||
Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file)
|
Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file)
|
||||||
shutil.move(backup_preferences_file, preferences_file)
|
shutil.move(backup_preferences_file, preferences_file)
|
||||||
|
|
||||||
|
# Read the preferences from the newly restored configuration (or else the cached Preferences will override the restored ones)
|
||||||
|
self._application.readPreferencesFromConfiguration()
|
||||||
|
|
||||||
|
# Restore the obfuscated settings
|
||||||
|
self._illuminate(**secrets)
|
||||||
|
|
||||||
return extracted
|
return extracted
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -167,9 +193,36 @@ class Backup:
|
||||||
Logger.log("d", "Removing current data in location: %s", target_path)
|
Logger.log("d", "Removing current data in location: %s", target_path)
|
||||||
Resources.factoryReset()
|
Resources.factoryReset()
|
||||||
Logger.log("d", "Extracting backup to location: %s", target_path)
|
Logger.log("d", "Extracting backup to location: %s", target_path)
|
||||||
|
name_list = archive.namelist()
|
||||||
|
for archive_filename in name_list:
|
||||||
try:
|
try:
|
||||||
archive.extractall(target_path)
|
archive.extract(archive_filename, target_path)
|
||||||
except (PermissionError, EnvironmentError):
|
except (PermissionError, EnvironmentError):
|
||||||
Logger.logException("e", "Unable to extract the backup due to permission or file system errors.")
|
Logger.logException("e", f"Unable to extract the file {archive_filename} from the backup due to permission or file system errors.")
|
||||||
return False
|
CuraApplication.getInstance().processEvents()
|
||||||
return True
|
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()
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
from typing import Dict, Optional, Tuple, TYPE_CHECKING
|
from typing import Dict, Optional, Tuple, TYPE_CHECKING
|
||||||
|
|
||||||
from UM.Logger import Logger
|
from UM.Logger import Logger
|
||||||
|
from UM.Version import Version
|
||||||
from cura.Backups.Backup import Backup
|
from cura.Backups.Backup import Backup
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
@ -52,6 +53,7 @@ class BackupsManager:
|
||||||
|
|
||||||
backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data)
|
backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data)
|
||||||
restored = backup.restore()
|
restored = backup.restore()
|
||||||
|
|
||||||
if restored:
|
if restored:
|
||||||
# At this point, Cura will need to restart for the changes to take effect.
|
# At this point, Cura will need to restart for the changes to take effect.
|
||||||
# We don't want to store the data at this point as that would override the just-restored backup.
|
# We don't want to store the data at this point as that would override the just-restored backup.
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2020 Ultimaker B.V.
|
# Copyright (c) 2021 Ultimaker B.V.
|
||||||
# Cura is released under the terms of the LGPLv3 or higher.
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
|
@ -916,6 +916,8 @@ class BuildVolume(SceneNode):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
for area in self._global_container_stack.getProperty("machine_disallowed_areas", "value"):
|
for area in self._global_container_stack.getProperty("machine_disallowed_areas", "value"):
|
||||||
|
if len(area) == 0:
|
||||||
|
continue # Numpy doesn't deal well with 0-length arrays, since it can't determine the dimensionality of them.
|
||||||
polygon = Polygon(numpy.array(area, numpy.float32))
|
polygon = Polygon(numpy.array(area, numpy.float32))
|
||||||
polygon = polygon.getMinkowskiHull(Polygon.approximatedCircle(border_size))
|
polygon = polygon.getMinkowskiHull(Polygon.approximatedCircle(border_size))
|
||||||
machine_disallowed_polygons.append(polygon)
|
machine_disallowed_polygons.append(polygon)
|
||||||
|
|
|
@ -67,11 +67,15 @@ class CuraActions(QObject):
|
||||||
current_node = parent_node
|
current_node = parent_node
|
||||||
parent_node = current_node.getParent()
|
parent_node = current_node.getParent()
|
||||||
|
|
||||||
# This was formerly done with SetTransformOperation but because of
|
# Find out where the bottom of the object is
|
||||||
# unpredictable matrix deconstruction it was possible that mirrors
|
bbox = current_node.getBoundingBox()
|
||||||
# could manifest as rotations. Centering is therefore done by
|
if bbox:
|
||||||
# moving the node to negative whatever its position is:
|
center_y = current_node.getWorldPosition().y - bbox.bottom
|
||||||
center_operation = TranslateOperation(current_node, -current_node._position)
|
else:
|
||||||
|
center_y = 0
|
||||||
|
|
||||||
|
# Move the object so that it's bottom is on to of the buildplate
|
||||||
|
center_operation = TranslateOperation(current_node, Vector(0, center_y, 0), set_position = True)
|
||||||
operation.addOperation(center_operation)
|
operation.addOperation(center_operation)
|
||||||
operation.push()
|
operation.push()
|
||||||
|
|
||||||
|
|
|
@ -129,7 +129,7 @@ class CuraApplication(QtApplication):
|
||||||
# SettingVersion represents the set of settings available in the machine/extruder definitions.
|
# 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
|
# You need to make sure that this version number needs to be increased if there is any non-backwards-compatible
|
||||||
# changes of the settings.
|
# changes of the settings.
|
||||||
SettingVersion = 16
|
SettingVersion = 17
|
||||||
|
|
||||||
Created = False
|
Created = False
|
||||||
|
|
||||||
|
@ -257,6 +257,9 @@ class CuraApplication(QtApplication):
|
||||||
from cura.CuraPackageManager import CuraPackageManager
|
from cura.CuraPackageManager import CuraPackageManager
|
||||||
self._package_manager_class = CuraPackageManager
|
self._package_manager_class = CuraPackageManager
|
||||||
|
|
||||||
|
from UM.CentralFileStorage import CentralFileStorage
|
||||||
|
CentralFileStorage.setIsEnterprise(ApplicationMetadata.IsEnterpriseVersion)
|
||||||
|
|
||||||
@pyqtProperty(str, constant=True)
|
@pyqtProperty(str, constant=True)
|
||||||
def ultimakerCloudApiRootUrl(self) -> str:
|
def ultimakerCloudApiRootUrl(self) -> str:
|
||||||
return UltimakerCloudConstants.CuraCloudAPIRoot
|
return UltimakerCloudConstants.CuraCloudAPIRoot
|
||||||
|
@ -467,6 +470,7 @@ class CuraApplication(QtApplication):
|
||||||
("user", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.UserInstanceContainer, "application/x-uranium-instancecontainer"),
|
("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"),
|
("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"),
|
("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 +607,15 @@ class CuraApplication(QtApplication):
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
def closeApplication(self) -> None:
|
def closeApplication(self) -> None:
|
||||||
Logger.log("i", "Close application")
|
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()
|
main_window = self.getMainWindow()
|
||||||
if main_window is not None:
|
if main_window is not None:
|
||||||
main_window.close()
|
main_window.close()
|
||||||
|
@ -695,6 +708,8 @@ class CuraApplication(QtApplication):
|
||||||
@pyqtSlot(str)
|
@pyqtSlot(str)
|
||||||
def discardOrKeepProfileChangesClosed(self, option: str) -> None:
|
def discardOrKeepProfileChangesClosed(self, option: str) -> None:
|
||||||
global_stack = self.getGlobalContainerStack()
|
global_stack = self.getGlobalContainerStack()
|
||||||
|
if global_stack is None:
|
||||||
|
return
|
||||||
if option == "discard":
|
if option == "discard":
|
||||||
for extruder in global_stack.extruderList:
|
for extruder in global_stack.extruderList:
|
||||||
extruder.userChanges.clear()
|
extruder.userChanges.clear()
|
||||||
|
@ -1516,12 +1531,8 @@ class CuraApplication(QtApplication):
|
||||||
|
|
||||||
# Compute the center of the objects
|
# Compute the center of the objects
|
||||||
object_centers = []
|
object_centers = []
|
||||||
# Forget about the translation that the original objects have
|
|
||||||
zero_translation = Matrix(data=numpy.zeros(3))
|
|
||||||
for mesh, node in zip(meshes, group_node.getChildren()):
|
for mesh, node in zip(meshes, group_node.getChildren()):
|
||||||
transformation = node.getLocalTransformation()
|
transformed_mesh = mesh.getTransformed(Matrix()) # Forget about the transformations that the original object had.
|
||||||
transformation.setTranslation(zero_translation)
|
|
||||||
transformed_mesh = mesh.getTransformed(transformation)
|
|
||||||
center = transformed_mesh.getCenterPosition()
|
center = transformed_mesh.getCenterPosition()
|
||||||
if center is not None:
|
if center is not None:
|
||||||
object_centers.append(center)
|
object_centers.append(center)
|
||||||
|
@ -1536,7 +1547,7 @@ class CuraApplication(QtApplication):
|
||||||
|
|
||||||
# Move each node to the same position.
|
# Move each node to the same position.
|
||||||
for mesh, node in zip(meshes, group_node.getChildren()):
|
for mesh, node in zip(meshes, group_node.getChildren()):
|
||||||
node.setTransformation(Matrix())
|
node.setTransformation(Matrix()) # Removes any changes in position and rotation.
|
||||||
# Align the object around its zero position
|
# Align the object around its zero position
|
||||||
# and also apply the offset to center it inside the group.
|
# and also apply the offset to center it inside the group.
|
||||||
node.setPosition(-mesh.getZeroPosition() - offset)
|
node.setPosition(-mesh.getZeroPosition() - offset)
|
||||||
|
@ -1857,6 +1868,7 @@ class CuraApplication(QtApplication):
|
||||||
else:
|
else:
|
||||||
node = CuraSceneNode()
|
node = CuraSceneNode()
|
||||||
node.setMeshData(original_node.getMeshData())
|
node.setMeshData(original_node.getMeshData())
|
||||||
|
node.source_mime_type = original_node.source_mime_type
|
||||||
|
|
||||||
# Setting meshdata does not apply scaling.
|
# Setting meshdata does not apply scaling.
|
||||||
if original_node.getScale() != Vector(1.0, 1.0, 1.0):
|
if original_node.getScale() != Vector(1.0, 1.0, 1.0):
|
||||||
|
|
|
@ -73,7 +73,7 @@ class LayerPolygon:
|
||||||
|
|
||||||
# When type is used as index returns true if type == LayerPolygon.InfillType or type == LayerPolygon.SkinType or type == LayerPolygon.SupportInfillType
|
# 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.
|
# 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_line_mesh_mask = None # type: Optional[numpy.ndarray]
|
||||||
self._build_cache_needed_points = None # type: Optional[numpy.ndarray]
|
self._build_cache_needed_points = None # type: Optional[numpy.ndarray]
|
||||||
|
@ -81,18 +81,17 @@ class LayerPolygon:
|
||||||
def buildCache(self) -> None:
|
def buildCache(self) -> None:
|
||||||
# For the line mesh we do not draw Infill or Jumps. Therefore those lines are filtered out.
|
# 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)
|
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_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
|
# 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]
|
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
|
# 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 )
|
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_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:
|
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
|
"""Set all the arrays provided by the function caller, representing the LayerPolygon
|
||||||
|
|
|
@ -53,6 +53,9 @@ class ExtrudersModel(ListModel):
|
||||||
EnabledRole = Qt.UserRole + 11
|
EnabledRole = Qt.UserRole + 11
|
||||||
"""Is the extruder enabled?"""
|
"""Is the extruder enabled?"""
|
||||||
|
|
||||||
|
MaterialTypeRole = Qt.UserRole + 12
|
||||||
|
"""The type of the material (e.g. PLA, ABS, PETG, etc.)."""
|
||||||
|
|
||||||
defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"]
|
defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"]
|
||||||
"""List of colours to display if there is no material or the material has no known colour. """
|
"""List of colours to display if there is no material or the material has no known colour. """
|
||||||
|
|
||||||
|
@ -75,6 +78,7 @@ class ExtrudersModel(ListModel):
|
||||||
self.addRoleName(self.StackRole, "stack")
|
self.addRoleName(self.StackRole, "stack")
|
||||||
self.addRoleName(self.MaterialBrandRole, "material_brand")
|
self.addRoleName(self.MaterialBrandRole, "material_brand")
|
||||||
self.addRoleName(self.ColorNameRole, "color_name")
|
self.addRoleName(self.ColorNameRole, "color_name")
|
||||||
|
self.addRoleName(self.MaterialTypeRole, "material_type")
|
||||||
self._update_extruder_timer = QTimer()
|
self._update_extruder_timer = QTimer()
|
||||||
self._update_extruder_timer.setInterval(100)
|
self._update_extruder_timer.setInterval(100)
|
||||||
self._update_extruder_timer.setSingleShot(True)
|
self._update_extruder_timer.setSingleShot(True)
|
||||||
|
@ -193,7 +197,8 @@ class ExtrudersModel(ListModel):
|
||||||
"variant": extruder.variant.getName() if extruder.variant else "", # e.g. print core
|
"variant": extruder.variant.getName() if extruder.variant else "", # e.g. print core
|
||||||
"stack": extruder,
|
"stack": extruder,
|
||||||
"material_brand": material_brand,
|
"material_brand": material_brand,
|
||||||
"color_name": color_name
|
"color_name": color_name,
|
||||||
|
"material_type": extruder.material.getMetaDataEntry("material") if extruder.material else "",
|
||||||
}
|
}
|
||||||
|
|
||||||
items.append(item)
|
items.append(item)
|
||||||
|
@ -210,7 +215,7 @@ class ExtrudersModel(ListModel):
|
||||||
"id": "",
|
"id": "",
|
||||||
"name": catalog.i18nc("@menuitem", "Not overridden"),
|
"name": catalog.i18nc("@menuitem", "Not overridden"),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"color": "#ffffff",
|
"color": "transparent",
|
||||||
"index": -1,
|
"index": -1,
|
||||||
"definition": "",
|
"definition": "",
|
||||||
"material": "",
|
"material": "",
|
||||||
|
@ -218,6 +223,7 @@ class ExtrudersModel(ListModel):
|
||||||
"stack": None,
|
"stack": None,
|
||||||
"material_brand": "",
|
"material_brand": "",
|
||||||
"color_name": "",
|
"color_name": "",
|
||||||
|
"material_type": "",
|
||||||
}
|
}
|
||||||
items.append(item)
|
items.append(item)
|
||||||
if self._items != items:
|
if self._items != items:
|
||||||
|
|
|
@ -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.
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
@ -34,4 +34,4 @@ def fetchLayerHeight(quality_group: "QualityGroup") -> float:
|
||||||
if isinstance(layer_height, SettingFunction):
|
if isinstance(layer_height, SettingFunction):
|
||||||
layer_height = layer_height(global_stack)
|
layer_height = layer_height(global_stack)
|
||||||
|
|
||||||
return float(layer_height)
|
return round(float(layer_height), 3)
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
# Copyright (c) 2019 Ultimaker B.V.
|
# Copyright (c) 2021 Ultimaker B.V.
|
||||||
# Cura is released under the terms of the LGPLv3 or higher.
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
import copy # To duplicate materials.
|
import copy # To duplicate materials.
|
||||||
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot # To allow the preference page proxy to be used from the actual preferences page.
|
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl
|
||||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||||
import uuid # To generate new GUIDs for new materials.
|
import uuid # To generate new GUIDs for new materials.
|
||||||
|
import zipfile # To export all materials in a .zip archive.
|
||||||
|
|
||||||
from UM.i18n import i18nCatalog
|
from UM.i18n import i18nCatalog
|
||||||
from UM.Logger import Logger
|
from UM.Logger import Logger
|
||||||
|
@ -20,11 +21,6 @@ if TYPE_CHECKING:
|
||||||
catalog = i18nCatalog("cura")
|
catalog = i18nCatalog("cura")
|
||||||
|
|
||||||
class MaterialManagementModel(QObject):
|
class MaterialManagementModel(QObject):
|
||||||
"""Proxy class to the materials page in the preferences.
|
|
||||||
|
|
||||||
This class handles the actions in that page, such as creating new materials, renaming them, etc.
|
|
||||||
"""
|
|
||||||
|
|
||||||
favoritesChanged = pyqtSignal(str)
|
favoritesChanged = pyqtSignal(str)
|
||||||
"""Triggered when a favorite is added or removed.
|
"""Triggered when a favorite is added or removed.
|
||||||
|
|
||||||
|
@ -79,6 +75,7 @@ class MaterialManagementModel(QObject):
|
||||||
|
|
||||||
:param material_node: The material to remove.
|
:param material_node: The material to remove.
|
||||||
"""
|
"""
|
||||||
|
Logger.info(f"Removing material {material_node.container_id}")
|
||||||
|
|
||||||
container_registry = CuraContainerRegistry.getInstance()
|
container_registry = CuraContainerRegistry.getInstance()
|
||||||
materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file)
|
materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file)
|
||||||
|
@ -194,6 +191,7 @@ class MaterialManagementModel(QObject):
|
||||||
|
|
||||||
:return: The root material ID of the duplicate material.
|
:return: The root material ID of the duplicate material.
|
||||||
"""
|
"""
|
||||||
|
Logger.info(f"Duplicating material {material_node.base_file} to {new_base_id}")
|
||||||
return self.duplicateMaterialByBaseFile(material_node.base_file, new_base_id, new_metadata)
|
return self.duplicateMaterialByBaseFile(material_node.base_file, new_base_id, new_metadata)
|
||||||
|
|
||||||
@pyqtSlot(result = str)
|
@pyqtSlot(result = str)
|
||||||
|
@ -262,3 +260,40 @@ class MaterialManagementModel(QObject):
|
||||||
self.favoritesChanged.emit(material_base_file)
|
self.favoritesChanged.emit(material_base_file)
|
||||||
except ValueError: # Material was not in the favorites list.
|
except ValueError: # Material was not in the favorites list.
|
||||||
Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file))
|
Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file))
|
||||||
|
|
||||||
|
@pyqtSlot(result = QUrl)
|
||||||
|
def getPreferredExportAllPath(self) -> QUrl:
|
||||||
|
"""
|
||||||
|
Get the preferred path to export materials to.
|
||||||
|
|
||||||
|
If there is a removable drive, that should be the preferred path. Otherwise it should be the most recent local
|
||||||
|
file path.
|
||||||
|
:return: The preferred path to export all materials to.
|
||||||
|
"""
|
||||||
|
cura_application = cura.CuraApplication.CuraApplication.getInstance()
|
||||||
|
device_manager = cura_application.getOutputDeviceManager()
|
||||||
|
devices = device_manager.getOutputDevices()
|
||||||
|
for device in devices:
|
||||||
|
if device.__class__.__name__ == "RemovableDriveOutputDevice":
|
||||||
|
return QUrl.fromLocalFile(device.getId())
|
||||||
|
else: # No removable drives? Use local path.
|
||||||
|
return cura_application.getDefaultPath("dialog_material_path")
|
||||||
|
|
||||||
|
@pyqtSlot(QUrl)
|
||||||
|
def exportAll(self, file_path: QUrl) -> None:
|
||||||
|
"""
|
||||||
|
Export all materials to a certain file path.
|
||||||
|
:param file_path: The path to export the materials to.
|
||||||
|
"""
|
||||||
|
registry = CuraContainerRegistry.getInstance()
|
||||||
|
|
||||||
|
archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED)
|
||||||
|
for metadata in registry.findInstanceContainersMetadata(type = "material"):
|
||||||
|
if metadata["base_file"] != metadata["id"]: # Only process base files.
|
||||||
|
continue
|
||||||
|
if metadata["id"] == "empty_material": # Don't export the empty material.
|
||||||
|
continue
|
||||||
|
material = registry.findContainers(id = metadata["id"])[0]
|
||||||
|
suffix = registry.getMimeTypeForContainer(type(material)).preferredSuffix
|
||||||
|
filename = metadata["id"] + "." + suffix
|
||||||
|
archive.writestr(filename, material.serialize())
|
||||||
|
|
|
@ -99,7 +99,7 @@ class QualitySettingsModel(ListModel):
|
||||||
if self._selected_position == self.GLOBAL_STACK_POSITION:
|
if self._selected_position == self.GLOBAL_STACK_POSITION:
|
||||||
quality_node = quality_group.node_for_global
|
quality_node = quality_group.node_for_global
|
||||||
else:
|
else:
|
||||||
quality_node = quality_group.nodes_for_extruders.get(str(self._selected_position))
|
quality_node = quality_group.nodes_for_extruders.get(self._selected_position)
|
||||||
settings_keys = quality_group.getAllKeys()
|
settings_keys = quality_group.getAllKeys()
|
||||||
quality_containers = []
|
quality_containers = []
|
||||||
if quality_node is not None and quality_node.container is not None:
|
if quality_node is not None and quality_node.container is not None:
|
||||||
|
@ -114,10 +114,13 @@ class QualitySettingsModel(ListModel):
|
||||||
global_container = None if len(global_containers) == 0 else global_containers[0]
|
global_container = None if len(global_containers) == 0 else global_containers[0]
|
||||||
extruders_containers = {pos: container_registry.findContainers(id = quality_changes_group.metadata_per_extruder[pos]["id"]) for pos in quality_changes_group.metadata_per_extruder}
|
extruders_containers = {pos: container_registry.findContainers(id = quality_changes_group.metadata_per_extruder[pos]["id"]) for pos in quality_changes_group.metadata_per_extruder}
|
||||||
extruders_container = {pos: None if not containers else containers[0] for pos, containers in extruders_containers.items()}
|
extruders_container = {pos: None if not containers else containers[0] for pos, containers in extruders_containers.items()}
|
||||||
|
quality_changes_metadata = None
|
||||||
if self._selected_position == self.GLOBAL_STACK_POSITION and global_container:
|
if self._selected_position == self.GLOBAL_STACK_POSITION and global_container:
|
||||||
quality_changes_metadata = global_container.getMetaData()
|
quality_changes_metadata = global_container.getMetaData()
|
||||||
else:
|
else:
|
||||||
quality_changes_metadata = extruders_container.get(str(self._selected_position))
|
extruder = extruders_container.get(self._selected_position)
|
||||||
|
if extruder:
|
||||||
|
quality_changes_metadata = extruder.getMetaData()
|
||||||
if quality_changes_metadata is not None: # It can be None if number of extruders are changed during runtime.
|
if quality_changes_metadata is not None: # It can be None if number of extruders are changed during runtime.
|
||||||
container = container_registry.findContainers(id = quality_changes_metadata["id"])
|
container = container_registry.findContainers(id = quality_changes_metadata["id"])
|
||||||
if container:
|
if container:
|
||||||
|
|
|
@ -19,6 +19,8 @@ class SettingVisibilityPresetsModel(QObject):
|
||||||
onItemsChanged = pyqtSignal()
|
onItemsChanged = pyqtSignal()
|
||||||
activePresetChanged = pyqtSignal()
|
activePresetChanged = pyqtSignal()
|
||||||
|
|
||||||
|
Version = 2
|
||||||
|
|
||||||
def __init__(self, preferences: Preferences, parent = None) -> None:
|
def __init__(self, preferences: Preferences, parent = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
# Copyright (c) 2020 Ultimaker B.V.
|
# Copyright (c) 2021 Ultimaker B.V.
|
||||||
# Cura is released under the terms of the LGPLv3 or higher.
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
from hashlib import sha512
|
from hashlib import sha512
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from typing import Optional, Any, Dict, Tuple
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from UM.i18n import i18nCatalog
|
from UM.i18n import i18nCatalog
|
||||||
|
@ -115,7 +115,7 @@ class AuthorizationHelpers:
|
||||||
token_request = requests.get(check_token_url, headers = {
|
token_request = requests.get(check_token_url, headers = {
|
||||||
"Authorization": "Bearer {}".format(access_token)
|
"Authorization": "Bearer {}".format(access_token)
|
||||||
})
|
})
|
||||||
except requests.exceptions.ConnectionError:
|
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||||
# Connection was suddenly dropped. Nothing we can do about that.
|
# Connection was suddenly dropped. Nothing we can do about that.
|
||||||
Logger.logException("w", "Something failed while attempting to parse the JWT token")
|
Logger.logException("w", "Something failed while attempting to parse the JWT token")
|
||||||
return None
|
return None
|
||||||
|
|
|
@ -113,7 +113,9 @@ class AuthorizationService:
|
||||||
# The token could not be refreshed using the refresh token. We should login again.
|
# The token could not be refreshed using the refresh token. We should login again.
|
||||||
return None
|
return None
|
||||||
# Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been deleted
|
# Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been deleted
|
||||||
# from the server already.
|
# from the server already. Do not store the auth_data if we could not get new auth_data (eg due to a
|
||||||
|
# network error), since this would cause an infinite loop trying to get new auth-data
|
||||||
|
if self._auth_data.success:
|
||||||
self._storeAuthData(self._auth_data)
|
self._storeAuthData(self._auth_data)
|
||||||
return self._auth_helpers.parseJWT(self._auth_data.access_token)
|
return self._auth_helpers.parseJWT(self._auth_data.access_token)
|
||||||
|
|
||||||
|
@ -255,10 +257,9 @@ class AuthorizationService:
|
||||||
self._auth_data = auth_data
|
self._auth_data = auth_data
|
||||||
if auth_data:
|
if auth_data:
|
||||||
self._user_profile = self.getUserProfile()
|
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:
|
else:
|
||||||
self._user_profile = None
|
self._user_profile = None
|
||||||
self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)
|
self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)
|
||||||
|
|
||||||
self.accessTokenChanged.emit()
|
self.accessTokenChanged.emit()
|
||||||
|
|
||||||
|
|
83
cura/OAuth2/KeyringAttribute.py
Normal file
83
cura/OAuth2/KeyringAttribute.py
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
# 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, KeyringLocked
|
||||||
|
|
||||||
|
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)
|
||||||
|
except KeyringLocked:
|
||||||
|
self._store_secure = False
|
||||||
|
Logger.log("i", "Access to the keyring was denied.")
|
||||||
|
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, KeyringLocked):
|
||||||
|
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)
|
|
@ -54,6 +54,7 @@ class LocalAuthorizationServer:
|
||||||
if self._web_server:
|
if self._web_server:
|
||||||
# If the server is already running (because of a previously aborted auth flow), we don't have to start it.
|
# If the server is already running (because of a previously aborted auth flow), we don't have to start it.
|
||||||
# We still inject the new verification code though.
|
# We still inject the new verification code though.
|
||||||
|
Logger.log("d", "Auth web server was already running. Updating the verification code")
|
||||||
self._web_server.setVerificationCode(verification_code)
|
self._web_server.setVerificationCode(verification_code)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -85,6 +86,7 @@ class LocalAuthorizationServer:
|
||||||
except OSError:
|
except OSError:
|
||||||
# OS error can happen if the socket was already closed. We really don't care about that case.
|
# OS error can happen if the socket was already closed. We really don't care about that case.
|
||||||
pass
|
pass
|
||||||
|
Logger.log("d", "Local oauth2 web server was shut down")
|
||||||
self._web_server = None
|
self._web_server = None
|
||||||
self._web_server_thread = None
|
self._web_server_thread = None
|
||||||
|
|
||||||
|
@ -96,12 +98,13 @@ class LocalAuthorizationServer:
|
||||||
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
|
Logger.log("d", "Local web server for authorization has started")
|
||||||
if self._web_server:
|
if self._web_server:
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
try:
|
try:
|
||||||
self._web_server.serve_forever()
|
self._web_server.serve_forever()
|
||||||
except OSError as e:
|
except OSError:
|
||||||
Logger.warning(str(e))
|
Logger.logException("w", "An exception happened while serving the auth server")
|
||||||
else:
|
else:
|
||||||
# Leave the default behavior in non-windows platforms
|
# Leave the default behavior in non-windows platforms
|
||||||
self._web_server.serve_forever()
|
self._web_server.serve_forever()
|
||||||
|
|
|
@ -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.
|
# 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:
|
class BaseModel:
|
||||||
|
@ -37,12 +39,29 @@ class AuthenticationResponse(BaseModel):
|
||||||
# Data comes from the token response with success flag and error message added.
|
# Data comes from the token response with success flag and error message added.
|
||||||
success = True # type: bool
|
success = True # type: bool
|
||||||
token_type = None # type: Optional[str]
|
token_type = None # type: Optional[str]
|
||||||
access_token = None # type: Optional[str]
|
|
||||||
refresh_token = None # type: Optional[str]
|
|
||||||
expires_in = None # type: Optional[str]
|
expires_in = None # type: Optional[str]
|
||||||
scope = None # type: Optional[str]
|
scope = None # type: Optional[str]
|
||||||
err_message = None # type: Optional[str]
|
err_message = None # type: Optional[str]
|
||||||
received_at = 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):
|
class ResponseStatus(BaseModel):
|
||||||
|
|
|
@ -119,21 +119,23 @@ class CuraSceneNode(SceneNode):
|
||||||
self._aabb = None
|
self._aabb = None
|
||||||
if self._mesh_data:
|
if self._mesh_data:
|
||||||
self._aabb = self._mesh_data.getExtents(self.getWorldTransformation(copy = False))
|
self._aabb = self._mesh_data.getExtents(self.getWorldTransformation(copy = False))
|
||||||
else: # If there is no mesh_data, use a bounding box that encompasses the local (0,0,0)
|
|
||||||
position = self.getWorldPosition()
|
|
||||||
self._aabb = AxisAlignedBox(minimum = position, maximum = position)
|
|
||||||
|
|
||||||
for child in self.getAllChildren():
|
for child in self.getAllChildren():
|
||||||
if child.callDecoration("isNonPrintingMesh"):
|
if child.callDecoration("isNonPrintingMesh"):
|
||||||
# Non-printing-meshes inside a group should not affect push apart or drop to build plate
|
# Non-printing-meshes inside a group should not affect push apart or drop to build plate
|
||||||
continue
|
continue
|
||||||
if not child.getMeshData():
|
child_bb = child.getBoundingBox()
|
||||||
# Nodes without mesh data should not affect bounding boxes of their parents.
|
if child_bb is None or child_bb.minimum == child_bb.maximum:
|
||||||
|
# Child had a degenerate bounding box, such as an empty group. Don't count it along.
|
||||||
continue
|
continue
|
||||||
if self._aabb is None:
|
if self._aabb is None:
|
||||||
self._aabb = child.getBoundingBox()
|
self._aabb = child_bb
|
||||||
else:
|
else:
|
||||||
self._aabb = self._aabb + child.getBoundingBox()
|
self._aabb = self._aabb + child_bb
|
||||||
|
|
||||||
|
if self._aabb is None: # No children that should be included? Just use your own position then, but it's an invalid AABB.
|
||||||
|
position = self.getWorldPosition()
|
||||||
|
self._aabb = AxisAlignedBox(minimum = position, maximum = position)
|
||||||
|
|
||||||
def __deepcopy__(self, memo: Dict[int, object]) -> "CuraSceneNode":
|
def __deepcopy__(self, memo: Dict[int, object]) -> "CuraSceneNode":
|
||||||
"""Taken from SceneNode, but replaced SceneNode with CuraSceneNode"""
|
"""Taken from SceneNode, but replaced SceneNode with CuraSceneNode"""
|
||||||
|
@ -142,6 +144,7 @@ class CuraSceneNode(SceneNode):
|
||||||
copy.setTransformation(self.getLocalTransformation(copy= False))
|
copy.setTransformation(self.getLocalTransformation(copy= False))
|
||||||
copy.setMeshData(self._mesh_data)
|
copy.setMeshData(self._mesh_data)
|
||||||
copy.setVisible(cast(bool, deepcopy(self._visible, memo)))
|
copy.setVisible(cast(bool, deepcopy(self._visible, memo)))
|
||||||
|
copy.source_mime_type = cast(str, deepcopy(self.source_mime_type, memo))
|
||||||
copy._selectable = cast(bool, deepcopy(self._selectable, memo))
|
copy._selectable = cast(bool, deepcopy(self._selectable, memo))
|
||||||
copy._name = cast(str, deepcopy(self._name, memo))
|
copy._name = cast(str, deepcopy(self._name, memo))
|
||||||
for decorator in self._decorators:
|
for decorator in self._decorators:
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2020 Ultimaker B.V.
|
# Copyright (c) 2021 Ultimaker B.V.
|
||||||
# Cura is released under the terms of the LGPLv3 or higher.
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
@ -241,6 +241,7 @@ class ContainerManager(QObject):
|
||||||
file_url = file_url_or_string.toLocalFile()
|
file_url = file_url_or_string.toLocalFile()
|
||||||
else:
|
else:
|
||||||
file_url = file_url_or_string
|
file_url = file_url_or_string
|
||||||
|
Logger.info(f"Importing material from {file_url}")
|
||||||
|
|
||||||
if not file_url or not os.path.exists(file_url):
|
if not file_url or not os.path.exists(file_url):
|
||||||
return {"status": "error", "message": "Invalid path"}
|
return {"status": "error", "message": "Invalid path"}
|
||||||
|
|
|
@ -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.
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
@ -381,8 +381,9 @@ class CuraContainerRegistry(ContainerRegistry):
|
||||||
if profile_count > 1:
|
if profile_count > 1:
|
||||||
continue
|
continue
|
||||||
# Only one profile found, this should not ever be the case, so that profile needs to be removed!
|
# 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)
|
invalid_quality_changes = ContainerRegistry.getInstance().findContainersMetadata(name=profile_name)
|
||||||
|
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"])
|
self.removeContainer(invalid_quality_changes[0]["id"])
|
||||||
|
|
||||||
@override(ContainerRegistry)
|
@override(ContainerRegistry)
|
||||||
|
|
|
@ -86,6 +86,14 @@ class GlobalStack(CuraContainerStack):
|
||||||
def supportsNetworkConnection(self):
|
def supportsNetworkConnection(self):
|
||||||
return self.getMetaDataEntry("supports_network_connection", False)
|
return self.getMetaDataEntry("supports_network_connection", False)
|
||||||
|
|
||||||
|
@pyqtProperty(bool, constant = True)
|
||||||
|
def supportsMaterialExport(self):
|
||||||
|
"""
|
||||||
|
Whether the printer supports Cura's export format of material profiles.
|
||||||
|
:return: ``True`` if it supports it, or ``False`` if not.
|
||||||
|
"""
|
||||||
|
return self.getMetaDataEntry("supports_material_export", False)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def getLoadingPriority(cls) -> int:
|
def getLoadingPriority(cls) -> int:
|
||||||
return 2
|
return 2
|
||||||
|
|
|
@ -25,8 +25,8 @@ class Snapshot:
|
||||||
pixels = numpy.frombuffer(pixel_array, dtype=numpy.uint8).reshape([height, width, 4])
|
pixels = numpy.frombuffer(pixel_array, dtype=numpy.uint8).reshape([height, width, 4])
|
||||||
# Find indices of non zero pixels
|
# Find indices of non zero pixels
|
||||||
nonzero_pixels = numpy.nonzero(pixels)
|
nonzero_pixels = numpy.nonzero(pixels)
|
||||||
min_y, min_x, min_a_ = numpy.amin(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)
|
max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1) # type: ignore
|
||||||
|
|
||||||
return min_x, max_x, min_y, max_y
|
return min_x, max_x, min_y, max_y
|
||||||
|
|
||||||
|
|
|
@ -132,9 +132,26 @@ class ObjectsModel(ListModel):
|
||||||
|
|
||||||
is_group = bool(node.callDecoration("isGroup"))
|
is_group = bool(node.callDecoration("isGroup"))
|
||||||
|
|
||||||
|
name_handled_as_group = False
|
||||||
force_rename = False
|
force_rename = False
|
||||||
if not is_group:
|
if is_group:
|
||||||
# Handle names for individual nodes
|
# 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 = node.getName()
|
||||||
|
|
||||||
name_match = self._naming_regex.fullmatch(name)
|
name_match = self._naming_regex.fullmatch(name)
|
||||||
|
@ -144,18 +161,6 @@ class ObjectsModel(ListModel):
|
||||||
else:
|
else:
|
||||||
original_name = name_match.groups()[0]
|
original_name = name_match.groups()[0]
|
||||||
name_index = int(name_match.groups()[1])
|
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:
|
if original_name not in name_to_node_info_dict:
|
||||||
# Keep track of 2 things:
|
# Keep track of 2 things:
|
||||||
|
|
|
@ -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.
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
|
@ -6,9 +6,11 @@ from typing import Optional, Dict, List, cast
|
||||||
|
|
||||||
from PyQt5.QtCore import QObject, pyqtSlot
|
from PyQt5.QtCore import QObject, pyqtSlot
|
||||||
|
|
||||||
|
from UM.i18n import i18nCatalog
|
||||||
from UM.Resources import Resources
|
from UM.Resources import Resources
|
||||||
from UM.Version import Version
|
from UM.Version import Version
|
||||||
|
|
||||||
|
catalog = i18nCatalog("cura")
|
||||||
|
|
||||||
#
|
#
|
||||||
# This manager provides means to load texts to QML.
|
# This manager provides means to load texts to QML.
|
||||||
|
@ -30,10 +32,11 @@ class TextManager(QObject):
|
||||||
# Load change log texts and organize them with a dict
|
# Load change log texts and organize them with a dict
|
||||||
try:
|
try:
|
||||||
file_path = Resources.getPath(Resources.Texts, "change_log.txt")
|
file_path = Resources.getPath(Resources.Texts, "change_log.txt")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError as e:
|
||||||
# I have no idea how / when this happens, but we're getting crash reports about it.
|
# I have no idea how / when this happens, but we're getting crash reports about it.
|
||||||
return ""
|
return catalog.i18nc("@text:window", "The release notes could not be opened.") + "<br>" + str(e)
|
||||||
change_logs_dict = {} # type: Dict[Version, Dict[str, List[str]]]
|
change_logs_dict = {} # type: Dict[Version, Dict[str, List[str]]]
|
||||||
|
try:
|
||||||
with open(file_path, "r", encoding = "utf-8") as f:
|
with open(file_path, "r", encoding = "utf-8") as f:
|
||||||
open_version = None # type: Optional[Version]
|
open_version = None # type: Optional[Version]
|
||||||
open_header = "" # Initialise to an empty header in case there is no "*" in the first line of the changelog
|
open_header = "" # Initialise to an empty header in case there is no "*" in the first line of the changelog
|
||||||
|
@ -54,6 +57,8 @@ class TextManager(QObject):
|
||||||
if open_header not in change_logs_dict[cast(Version, open_version)]:
|
if open_header not in change_logs_dict[cast(Version, open_version)]:
|
||||||
change_logs_dict[cast(Version, open_version)][open_header] = []
|
change_logs_dict[cast(Version, open_version)][open_header] = []
|
||||||
change_logs_dict[cast(Version, open_version)][open_header].append(line)
|
change_logs_dict[cast(Version, open_version)][open_header].append(line)
|
||||||
|
except EnvironmentError as e:
|
||||||
|
return catalog.i18nc("@text:window", "The release notes could not be opened.") + "<br>" + str(e)
|
||||||
|
|
||||||
# Format changelog text
|
# Format changelog text
|
||||||
content = ""
|
content = ""
|
||||||
|
|
|
@ -239,9 +239,6 @@ class WelcomePagesModel(ListModel):
|
||||||
{"id": "user_agreement",
|
{"id": "user_agreement",
|
||||||
"page_url": self._getBuiltinWelcomePagePath("UserAgreementContent.qml"),
|
"page_url": self._getBuiltinWelcomePagePath("UserAgreementContent.qml"),
|
||||||
},
|
},
|
||||||
{"id": "whats_new",
|
|
||||||
"page_url": self._getBuiltinWelcomePagePath("WhatsNewContent.qml"),
|
|
||||||
},
|
|
||||||
{"id": "data_collections",
|
{"id": "data_collections",
|
||||||
"page_url": self._getBuiltinWelcomePagePath("DataCollectionsContent.qml"),
|
"page_url": self._getBuiltinWelcomePagePath("DataCollectionsContent.qml"),
|
||||||
},
|
},
|
||||||
|
@ -259,13 +256,21 @@ class WelcomePagesModel(ListModel):
|
||||||
},
|
},
|
||||||
{"id": "add_cloud_printers",
|
{"id": "add_cloud_printers",
|
||||||
"page_url": self._getBuiltinWelcomePagePath("AddCloudPrintersView.qml"),
|
"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", "Next"),
|
||||||
"next_page_button_text": self._catalog.i18nc("@action:button", "Finish"),
|
"next_page_id": "whats_new",
|
||||||
},
|
},
|
||||||
{"id": "machine_actions",
|
{"id": "machine_actions",
|
||||||
"page_url": self._getBuiltinWelcomePagePath("FirstStartMachineActionsContent.qml"),
|
"page_url": self._getBuiltinWelcomePagePath("FirstStartMachineActionsContent.qml"),
|
||||||
"should_show_function": self.shouldShowMachineActions,
|
"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
|
pages_to_show = all_pages_list
|
||||||
|
|
|
@ -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.
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
from .WelcomePagesModel import WelcomePagesModel
|
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
|
# 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):
|
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.lower() 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:
|
def initialize(self) -> None:
|
||||||
self._pages = []
|
self._pages = []
|
||||||
self._pages.append({"id": "whats_new",
|
self._pages.append({"id": "whats_new",
|
||||||
"page_url": self._getBuiltinWelcomePagePath("WhatsNewContent.qml"),
|
"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"),
|
"next_page_button_text": self._catalog.i18nc("@action:button", "Close"),
|
||||||
})
|
})
|
||||||
self.setItems(self._pages)
|
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"]
|
__all__ = ["WhatsNewPagesModel"]
|
||||||
|
|
|
@ -16,14 +16,6 @@ import argparse
|
||||||
import faulthandler
|
import faulthandler
|
||||||
import os
|
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 PyQt5.QtNetwork import QSslConfiguration, QSslSocket
|
||||||
|
|
||||||
from UM.Platform import Platform
|
from UM.Platform import Platform
|
||||||
|
|
|
@ -7,7 +7,7 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||||
PROJECT_DIR="$( cd "${SCRIPT_DIR}/.." && pwd )"
|
PROJECT_DIR="$( cd "${SCRIPT_DIR}/.." && pwd )"
|
||||||
|
|
||||||
# Make sure that environment variables are set properly
|
# 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 PATH="${CURA_BUILD_ENV_PATH}/bin:${PATH}"
|
||||||
export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}"
|
export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}"
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2020 Ultimaker B.V.
|
# Copyright (c) 2021 Ultimaker B.V.
|
||||||
# Cura is released under the terms of the LGPLv3 or higher.
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
from configparser import ConfigParser
|
from configparser import ConfigParser
|
||||||
|
@ -412,7 +412,12 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
|
||||||
quality_container_id = parser["containers"][str(_ContainerIndexes.Quality)]
|
quality_container_id = parser["containers"][str(_ContainerIndexes.Quality)]
|
||||||
quality_type = "empty_quality"
|
quality_type = "empty_quality"
|
||||||
if quality_container_id not in ("empty", "empty_quality"):
|
if quality_container_id not in ("empty", "empty_quality"):
|
||||||
|
if quality_container_id in instance_container_info_dict:
|
||||||
quality_type = instance_container_info_dict[quality_container_id].parser["metadata"]["quality_type"]
|
quality_type = instance_container_info_dict[quality_container_id].parser["metadata"]["quality_type"]
|
||||||
|
else: # If a version upgrade changed the quality profile in the stack, we'll need to look for it in the built-in profiles instead of the workspace.
|
||||||
|
quality_matches = ContainerRegistry.getInstance().findContainersMetadata(id = quality_container_id)
|
||||||
|
if quality_matches: # If there's no profile with this ID, leave it empty_quality.
|
||||||
|
quality_type = quality_matches[0]["quality_type"]
|
||||||
|
|
||||||
# Get machine info
|
# Get machine info
|
||||||
serialized = archive.open(global_stack_file).read().decode("utf-8")
|
serialized = archive.open(global_stack_file).read().decode("utf-8")
|
||||||
|
@ -1157,7 +1162,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
|
||||||
return
|
return
|
||||||
machine_manager.setQualityChangesGroup(quality_changes_group, no_dialog = True)
|
machine_manager.setQualityChangesGroup(quality_changes_group, no_dialog = True)
|
||||||
else:
|
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()
|
quality_group_dict = container_tree.getCurrentQualityGroups()
|
||||||
if self._quality_type_to_apply in quality_group_dict:
|
if self._quality_type_to_apply in quality_group_dict:
|
||||||
quality_group = quality_group_dict[self._quality_type_to_apply]
|
quality_group = quality_group_dict[self._quality_type_to_apply]
|
||||||
|
|
|
@ -419,7 +419,7 @@ UM.Dialog
|
||||||
width: warningLabel.height
|
width: warningLabel.height
|
||||||
height: width
|
height: width
|
||||||
|
|
||||||
source: UM.Theme.getIcon("notice")
|
source: UM.Theme.getIcon("Information")
|
||||||
color: palette.text
|
color: palette.text
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,6 @@
|
||||||
"author": "Ultimaker B.V.",
|
"author": "Ultimaker B.V.",
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"description": "Provides support for reading 3MF files.",
|
"description": "Provides support for reading 3MF files.",
|
||||||
"api": "7.4.0",
|
"api": 7,
|
||||||
"i18n-catalog": "cura"
|
"i18n-catalog": "cura"
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,6 @@
|
||||||
"author": "Ultimaker B.V.",
|
"author": "Ultimaker B.V.",
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"description": "Provides support for writing 3MF files.",
|
"description": "Provides support for writing 3MF files.",
|
||||||
"api": "7.4.0",
|
"api": 7,
|
||||||
"i18n-catalog": "cura"
|
"i18n-catalog": "cura"
|
||||||
}
|
}
|
||||||
|
|
|
@ -157,22 +157,22 @@ class AMFReader(MeshReader):
|
||||||
tri_faces = tri_node.faces
|
tri_faces = tri_node.faces
|
||||||
tri_vertices = tri_node.vertices
|
tri_vertices = tri_node.vertices
|
||||||
|
|
||||||
indices = []
|
indices_list = []
|
||||||
vertices = []
|
vertices_list = []
|
||||||
|
|
||||||
index_count = 0
|
index_count = 0
|
||||||
face_count = 0
|
face_count = 0
|
||||||
for tri_face in tri_faces:
|
for tri_face in tri_faces:
|
||||||
face = []
|
face = []
|
||||||
for tri_index in tri_face:
|
for tri_index in tri_face:
|
||||||
vertices.append(tri_vertices[tri_index])
|
vertices_list.append(tri_vertices[tri_index])
|
||||||
face.append(index_count)
|
face.append(index_count)
|
||||||
index_count += 1
|
index_count += 1
|
||||||
indices.append(face)
|
indices_list.append(face)
|
||||||
face_count += 1
|
face_count += 1
|
||||||
|
|
||||||
vertices = numpy.asarray(vertices, dtype = numpy.float32)
|
vertices = numpy.asarray(vertices_list, dtype = numpy.float32)
|
||||||
indices = numpy.asarray(indices, dtype = numpy.int32)
|
indices = numpy.asarray(indices_list, dtype = numpy.int32)
|
||||||
normals = calculateNormalsFromIndexedVertices(vertices, indices, face_count)
|
normals = calculateNormalsFromIndexedVertices(vertices, indices, face_count)
|
||||||
|
|
||||||
mesh_data = MeshData(vertices = vertices, indices = indices, normals = normals,file_name = file_name)
|
mesh_data = MeshData(vertices = vertices, indices = indices, normals = normals,file_name = file_name)
|
||||||
|
|
|
@ -3,5 +3,5 @@
|
||||||
"author": "fieldOfView",
|
"author": "fieldOfView",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Provides support for reading AMF files.",
|
"description": "Provides support for reading AMF files.",
|
||||||
"api": "7.4.0"
|
"api": 7
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,6 @@
|
||||||
"author": "Ultimaker B.V.",
|
"author": "Ultimaker B.V.",
|
||||||
"description": "Backup and restore your configuration.",
|
"description": "Backup and restore your configuration.",
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"api": "7.4.0",
|
"api": 7,
|
||||||
"i18n-catalog": "cura"
|
"i18n-catalog": "cura"
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,6 @@ import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import sentry_sdk
|
|
||||||
from PyQt5.QtNetwork import QNetworkReply
|
from PyQt5.QtNetwork import QNetworkReply
|
||||||
|
|
||||||
from UM.Job import Job
|
from UM.Job import Job
|
||||||
|
@ -99,13 +98,7 @@ class CreateBackupJob(Job):
|
||||||
if HttpRequestManager.safeHttpStatus(reply) == 400:
|
if HttpRequestManager.safeHttpStatus(reply) == 400:
|
||||||
errors = json.loads(replyText)["errors"]
|
errors = json.loads(replyText)["errors"]
|
||||||
if "moreThanMaximum" in [error["code"] for error in errors if error["meta"] and error["meta"]["field_name"] == "backup_size"]:
|
if "moreThanMaximum" in [error["code"] for error in errors if error["meta"] and error["meta"]["field_name"] == "backup_size"]:
|
||||||
if self._backup_zip is None: # will never happen; keep mypy happy
|
|
||||||
zip_error = "backup is None."
|
|
||||||
else:
|
|
||||||
zip_error = "{} exceeds max size.".format(str(len(self._backup_zip)))
|
|
||||||
sentry_sdk.capture_message("backup failed: {}".format(zip_error), level ="warning")
|
|
||||||
self.backup_upload_error_message = catalog.i18nc("@error:file_size", "The backup exceeds the maximum file size.")
|
self.backup_upload_error_message = catalog.i18nc("@error:file_size", "The backup exceeds the maximum file size.")
|
||||||
from sentry_sdk import capture_message
|
|
||||||
|
|
||||||
self._job_done.set()
|
self._job_done.set()
|
||||||
return
|
return
|
||||||
|
|
|
@ -93,7 +93,7 @@ class DriveApiService:
|
||||||
def _onRestoreFinished(self, job: "RestoreBackupJob") -> None:
|
def _onRestoreFinished(self, job: "RestoreBackupJob") -> None:
|
||||||
if job.restore_backup_error_message != "":
|
if job.restore_backup_error_message != "":
|
||||||
# If the job contains an error message we pass it along so the UI can display it.
|
# If the job contains an error message we pass it along so the UI can display it.
|
||||||
self.restoringStateChanged.emit(is_restoring=False)
|
self.restoringStateChanged.emit(is_restoring = False)
|
||||||
else:
|
else:
|
||||||
self.restoringStateChanged.emit(is_restoring = False, error_message = job.restore_backup_error_message)
|
self.restoringStateChanged.emit(is_restoring = False, error_message = job.restore_backup_error_message)
|
||||||
|
|
||||||
|
|
|
@ -34,6 +34,9 @@ class DrivePluginExtension(QObject, Extension):
|
||||||
# Signal emitted when preferences changed (like auto-backup).
|
# Signal emitted when preferences changed (like auto-backup).
|
||||||
preferencesChanged = pyqtSignal()
|
preferencesChanged = pyqtSignal()
|
||||||
|
|
||||||
|
# Signal emitted when the id of the backup-to-be-restored is changed
|
||||||
|
backupIdBeingRestoredChanged = pyqtSignal(arguments = ["backup_id_being_restored"])
|
||||||
|
|
||||||
DATE_FORMAT = "%d/%m/%Y %H:%M:%S"
|
DATE_FORMAT = "%d/%m/%Y %H:%M:%S"
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
@ -45,6 +48,7 @@ class DrivePluginExtension(QObject, Extension):
|
||||||
self._backups = [] # type: List[Dict[str, Any]]
|
self._backups = [] # type: List[Dict[str, Any]]
|
||||||
self._is_restoring_backup = False
|
self._is_restoring_backup = False
|
||||||
self._is_creating_backup = False
|
self._is_creating_backup = False
|
||||||
|
self._backup_id_being_restored = ""
|
||||||
|
|
||||||
# Initialize services.
|
# Initialize services.
|
||||||
preferences = CuraApplication.getInstance().getPreferences()
|
preferences = CuraApplication.getInstance().getPreferences()
|
||||||
|
@ -52,6 +56,7 @@ class DrivePluginExtension(QObject, Extension):
|
||||||
|
|
||||||
# Attach signals.
|
# Attach signals.
|
||||||
CuraApplication.getInstance().getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged)
|
CuraApplication.getInstance().getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged)
|
||||||
|
CuraApplication.getInstance().applicationShuttingDown.connect(self._onApplicationShuttingDown)
|
||||||
self._drive_api_service.restoringStateChanged.connect(self._onRestoringStateChanged)
|
self._drive_api_service.restoringStateChanged.connect(self._onRestoringStateChanged)
|
||||||
self._drive_api_service.creatingStateChanged.connect(self._onCreatingStateChanged)
|
self._drive_api_service.creatingStateChanged.connect(self._onCreatingStateChanged)
|
||||||
|
|
||||||
|
@ -75,6 +80,10 @@ class DrivePluginExtension(QObject, Extension):
|
||||||
if self._drive_window:
|
if self._drive_window:
|
||||||
self._drive_window.show()
|
self._drive_window.show()
|
||||||
|
|
||||||
|
def _onApplicationShuttingDown(self):
|
||||||
|
if self._drive_window:
|
||||||
|
self._drive_window.hide()
|
||||||
|
|
||||||
def _autoBackup(self) -> None:
|
def _autoBackup(self) -> None:
|
||||||
preferences = CuraApplication.getInstance().getPreferences()
|
preferences = CuraApplication.getInstance().getPreferences()
|
||||||
if preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY) and self._isLastBackupTooLongAgo():
|
if preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY) and self._isLastBackupTooLongAgo():
|
||||||
|
@ -100,10 +109,11 @@ class DrivePluginExtension(QObject, Extension):
|
||||||
if logged_in:
|
if logged_in:
|
||||||
self.refreshBackups()
|
self.refreshBackups()
|
||||||
|
|
||||||
def _onRestoringStateChanged(self, is_restoring: bool = False, error_message: str = None) -> None:
|
def _onRestoringStateChanged(self, is_restoring: bool = False, error_message: Optional[str] = None) -> None:
|
||||||
self._is_restoring_backup = is_restoring
|
self._is_restoring_backup = is_restoring
|
||||||
self.restoringStateChanged.emit()
|
self.restoringStateChanged.emit()
|
||||||
if error_message:
|
if error_message:
|
||||||
|
self.backupIdBeingRestored = ""
|
||||||
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
|
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
|
||||||
|
|
||||||
def _onCreatingStateChanged(self, is_creating: bool = False, error_message: str = None) -> None:
|
def _onCreatingStateChanged(self, is_creating: bool = False, error_message: str = None) -> None:
|
||||||
|
@ -152,6 +162,7 @@ class DrivePluginExtension(QObject, Extension):
|
||||||
for backup in self._backups:
|
for backup in self._backups:
|
||||||
if backup.get("backup_id") == backup_id:
|
if backup.get("backup_id") == backup_id:
|
||||||
self._drive_api_service.restoreBackup(backup)
|
self._drive_api_service.restoreBackup(backup)
|
||||||
|
self.setBackupIdBeingRestored(backup_id)
|
||||||
return
|
return
|
||||||
Logger.log("w", "Unable to find backup with the ID %s", backup_id)
|
Logger.log("w", "Unable to find backup with the ID %s", backup_id)
|
||||||
|
|
||||||
|
@ -166,3 +177,12 @@ class DrivePluginExtension(QObject, Extension):
|
||||||
def _backupDeletedCallback(self, success: bool):
|
def _backupDeletedCallback(self, success: bool):
|
||||||
if success:
|
if success:
|
||||||
self.refreshBackups()
|
self.refreshBackups()
|
||||||
|
|
||||||
|
def setBackupIdBeingRestored(self, backup_id_being_restored: str) -> None:
|
||||||
|
if backup_id_being_restored != self._backup_id_being_restored:
|
||||||
|
self._backup_id_being_restored = backup_id_being_restored
|
||||||
|
self.backupIdBeingRestoredChanged.emit()
|
||||||
|
|
||||||
|
@pyqtProperty(str, fset = setBackupIdBeingRestored, notify = backupIdBeingRestoredChanged)
|
||||||
|
def backupIdBeingRestored(self) -> str:
|
||||||
|
return self._backup_id_being_restored
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
# Copyright (c) 2021 Ultimaker B.V.
|
||||||
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import threading
|
import threading
|
||||||
|
@ -56,6 +59,7 @@ class RestoreBackupJob(Job):
|
||||||
return
|
return
|
||||||
|
|
||||||
# We store the file in a temporary path fist to ensure integrity.
|
# We store the file in a temporary path fist to ensure integrity.
|
||||||
|
try:
|
||||||
temporary_backup_file = NamedTemporaryFile(delete = False)
|
temporary_backup_file = NamedTemporaryFile(delete = False)
|
||||||
with open(temporary_backup_file.name, "wb") as write_backup:
|
with open(temporary_backup_file.name, "wb") as write_backup:
|
||||||
app = CuraApplication.getInstance()
|
app = CuraApplication.getInstance()
|
||||||
|
@ -64,6 +68,11 @@ class RestoreBackupJob(Job):
|
||||||
write_backup.write(bytes_read)
|
write_backup.write(bytes_read)
|
||||||
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
|
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
|
||||||
app.processEvents()
|
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", "")):
|
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.
|
# Don't restore the backup if the MD5 hashes do not match.
|
||||||
|
|
|
@ -20,7 +20,7 @@ RowLayout
|
||||||
{
|
{
|
||||||
id: infoButton
|
id: infoButton
|
||||||
text: catalog.i18nc("@button", "Want more?")
|
text: catalog.i18nc("@button", "Want more?")
|
||||||
iconSource: UM.Theme.getIcon("info")
|
iconSource: UM.Theme.getIcon("Information")
|
||||||
onClicked: Qt.openUrlExternally("https://goo.gl/forms/QACEP8pP3RV60QYG2")
|
onClicked: Qt.openUrlExternally("https://goo.gl/forms/QACEP8pP3RV60QYG2")
|
||||||
visible: backupListFooter.showInfoButton
|
visible: backupListFooter.showInfoButton
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@ RowLayout
|
||||||
{
|
{
|
||||||
id: createBackupButton
|
id: createBackupButton
|
||||||
text: catalog.i18nc("@button", "Backup Now")
|
text: catalog.i18nc("@button", "Backup Now")
|
||||||
iconSource: UM.Theme.getIcon("plus")
|
iconSource: UM.Theme.getIcon("Plus")
|
||||||
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
|
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
|
||||||
onClicked: CuraDrive.createBackup()
|
onClicked: CuraDrive.createBackup()
|
||||||
busy: CuraDrive.isCreatingBackup
|
busy: CuraDrive.isCreatingBackup
|
||||||
|
|
|
@ -38,7 +38,7 @@ Item
|
||||||
height: UM.Theme.getSize("section_icon").height
|
height: UM.Theme.getSize("section_icon").height
|
||||||
color: UM.Theme.getColor("small_button_text")
|
color: UM.Theme.getColor("small_button_text")
|
||||||
hoverColor: UM.Theme.getColor("small_button_text_hover")
|
hoverColor: UM.Theme.getColor("small_button_text_hover")
|
||||||
iconSource: UM.Theme.getIcon("info")
|
iconSource: UM.Theme.getIcon("Information")
|
||||||
onClicked: backupListItem.showDetails = !backupListItem.showDetails
|
onClicked: backupListItem.showDetails = !backupListItem.showDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,6 +71,7 @@ Item
|
||||||
text: catalog.i18nc("@button", "Restore")
|
text: catalog.i18nc("@button", "Restore")
|
||||||
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
|
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
|
||||||
onClicked: confirmRestoreDialog.visible = true
|
onClicked: confirmRestoreDialog.visible = true
|
||||||
|
busy: CuraDrive.backupIdBeingRestored == modelData.backup_id && CuraDrive.isRestoringBackup
|
||||||
}
|
}
|
||||||
|
|
||||||
UM.SimpleButton
|
UM.SimpleButton
|
||||||
|
@ -79,7 +80,7 @@ Item
|
||||||
height: UM.Theme.getSize("message_close").height
|
height: UM.Theme.getSize("message_close").height
|
||||||
color: UM.Theme.getColor("small_button_text")
|
color: UM.Theme.getColor("small_button_text")
|
||||||
hoverColor: UM.Theme.getColor("small_button_text_hover")
|
hoverColor: UM.Theme.getColor("small_button_text_hover")
|
||||||
iconSource: UM.Theme.getIcon("cross1")
|
iconSource: UM.Theme.getIcon("Cancel")
|
||||||
onClicked: confirmDeleteDialog.visible = true
|
onClicked: confirmDeleteDialog.visible = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
// Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
import QtQuick 2.7
|
import QtQuick 2.7
|
||||||
|
@ -17,7 +17,7 @@ ColumnLayout
|
||||||
// Cura version
|
// Cura version
|
||||||
BackupListItemDetailsRow
|
BackupListItemDetailsRow
|
||||||
{
|
{
|
||||||
iconSource: UM.Theme.getIcon("application")
|
iconSource: UM.Theme.getIcon("UltimakerCura")
|
||||||
label: catalog.i18nc("@backuplist:label", "Cura Version")
|
label: catalog.i18nc("@backuplist:label", "Cura Version")
|
||||||
value: backupDetailsData.metadata.cura_release
|
value: backupDetailsData.metadata.cura_release
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,7 @@ ColumnLayout
|
||||||
// Machine count.
|
// Machine count.
|
||||||
BackupListItemDetailsRow
|
BackupListItemDetailsRow
|
||||||
{
|
{
|
||||||
iconSource: UM.Theme.getIcon("printer_single")
|
iconSource: UM.Theme.getIcon("Printer")
|
||||||
label: catalog.i18nc("@backuplist:label", "Machines")
|
label: catalog.i18nc("@backuplist:label", "Machines")
|
||||||
value: backupDetailsData.metadata.machine_count
|
value: backupDetailsData.metadata.machine_count
|
||||||
}
|
}
|
||||||
|
@ -33,7 +33,7 @@ ColumnLayout
|
||||||
// Material count
|
// Material count
|
||||||
BackupListItemDetailsRow
|
BackupListItemDetailsRow
|
||||||
{
|
{
|
||||||
iconSource: UM.Theme.getIcon("category_material")
|
iconSource: UM.Theme.getIcon("Spool")
|
||||||
label: catalog.i18nc("@backuplist:label", "Materials")
|
label: catalog.i18nc("@backuplist:label", "Materials")
|
||||||
value: backupDetailsData.metadata.material_count
|
value: backupDetailsData.metadata.material_count
|
||||||
}
|
}
|
||||||
|
@ -41,7 +41,7 @@ ColumnLayout
|
||||||
// Profile count.
|
// Profile count.
|
||||||
BackupListItemDetailsRow
|
BackupListItemDetailsRow
|
||||||
{
|
{
|
||||||
iconSource: UM.Theme.getIcon("settings")
|
iconSource: UM.Theme.getIcon("Sliders")
|
||||||
label: catalog.i18nc("@backuplist:label", "Profiles")
|
label: catalog.i18nc("@backuplist:label", "Profiles")
|
||||||
value: backupDetailsData.metadata.profile_count
|
value: backupDetailsData.metadata.profile_count
|
||||||
}
|
}
|
||||||
|
@ -49,7 +49,7 @@ ColumnLayout
|
||||||
// Plugin count.
|
// Plugin count.
|
||||||
BackupListItemDetailsRow
|
BackupListItemDetailsRow
|
||||||
{
|
{
|
||||||
iconSource: UM.Theme.getIcon("plugin")
|
iconSource: UM.Theme.getIcon("Plugin")
|
||||||
label: catalog.i18nc("@backuplist:label", "Plugins")
|
label: catalog.i18nc("@backuplist:label", "Plugins")
|
||||||
value: backupDetailsData.metadata.plugin_count
|
value: backupDetailsData.metadata.plugin_count
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,12 +4,12 @@
|
||||||
import argparse #To run the engine in debug mode if the front-end is in debug mode.
|
import argparse #To run the engine in debug mode if the front-end is in debug mode.
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import os
|
import os
|
||||||
from PyQt5.QtCore import QObject, QTimer, pyqtSlot
|
from PyQt5.QtCore import QObject, QTimer, QUrl, pyqtSlot
|
||||||
import sys
|
import sys
|
||||||
from time import time
|
from time import time
|
||||||
from typing import Any, cast, Dict, List, Optional, Set, TYPE_CHECKING
|
from typing import Any, cast, Dict, List, Optional, Set, TYPE_CHECKING
|
||||||
|
|
||||||
from PyQt5.QtGui import QImage
|
from PyQt5.QtGui import QDesktopServices, QImage
|
||||||
|
|
||||||
from UM.Backend.Backend import Backend, BackendState
|
from UM.Backend.Backend import Backend, BackendState
|
||||||
from UM.Scene.SceneNode import SceneNode
|
from UM.Scene.SceneNode import SceneNode
|
||||||
|
@ -157,6 +157,18 @@ class CuraEngineBackend(QObject, Backend):
|
||||||
self.determineAutoSlicing()
|
self.determineAutoSlicing()
|
||||||
application.getPreferences().preferenceChanged.connect(self._onPreferencesChanged)
|
application.getPreferences().preferenceChanged.connect(self._onPreferencesChanged)
|
||||||
|
|
||||||
|
self._slicing_error_message = Message(
|
||||||
|
text = catalog.i18nc("@message", "Slicing failed with an unexpected error. Please consider reporting a bug on our issue tracker."),
|
||||||
|
title = catalog.i18nc("@message:title", "Slicing failed")
|
||||||
|
)
|
||||||
|
self._slicing_error_message.addAction(
|
||||||
|
action_id = "report_bug",
|
||||||
|
name = catalog.i18nc("@message:button", "Report a bug"),
|
||||||
|
description = catalog.i18nc("@message:description", "Report a bug on Ultimaker Cura's issue tracker."),
|
||||||
|
icon = "[no_icon]"
|
||||||
|
)
|
||||||
|
self._slicing_error_message.actionTriggered.connect(self._reportBackendError)
|
||||||
|
|
||||||
self._snapshot = None #type: Optional[QImage]
|
self._snapshot = None #type: Optional[QImage]
|
||||||
|
|
||||||
application.initializationFinished.connect(self.initialize)
|
application.initializationFinished.connect(self.initialize)
|
||||||
|
@ -922,9 +934,22 @@ class CuraEngineBackend(QObject, Backend):
|
||||||
|
|
||||||
if not self._restart:
|
if not self._restart:
|
||||||
if self._process: # type: ignore
|
if self._process: # type: ignore
|
||||||
Logger.log("d", "Backend quit with return code %s. Resetting process and socket.", self._process.wait()) # type: ignore
|
return_code = self._process.wait()
|
||||||
|
if return_code != 0:
|
||||||
|
Logger.log("e", f"Backend exited abnormally with return code {return_code}!")
|
||||||
|
self._slicing_error_message.show()
|
||||||
|
self.setState(BackendState.Error)
|
||||||
|
self.stopSlicing()
|
||||||
|
else:
|
||||||
|
Logger.log("d", "Backend finished slicing. Resetting process and socket.")
|
||||||
self._process = None # type: ignore
|
self._process = None # type: ignore
|
||||||
|
|
||||||
|
def _reportBackendError(self, _message_id: str, _action_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Triggered when the user wants to report an error in the back-end.
|
||||||
|
"""
|
||||||
|
QDesktopServices.openUrl(QUrl("https://github.com/Ultimaker/Cura/issues/new/choose"))
|
||||||
|
|
||||||
def _onGlobalStackChanged(self) -> None:
|
def _onGlobalStackChanged(self) -> None:
|
||||||
"""Called when the global container stack changes"""
|
"""Called when the global container stack changes"""
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "CuraEngine Backend",
|
"name": "CuraEngine Backend",
|
||||||
"author": "Ultimaker B.V.",
|
"author": "Ultimaker B.V.",
|
||||||
"description": "Provides the link to the CuraEngine slicing backend.",
|
"description": "Provides the link to the CuraEngine slicing backend.",
|
||||||
"api": "7.4.0",
|
"api": 7,
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"i18n-catalog": "cura"
|
"i18n-catalog": "cura"
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,6 @@
|
||||||
"author": "Ultimaker B.V.",
|
"author": "Ultimaker B.V.",
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"description": "Provides support for importing Cura profiles.",
|
"description": "Provides support for importing Cura profiles.",
|
||||||
"api": "7.4.0",
|
"api": 7,
|
||||||
"i18n-catalog": "cura"
|
"i18n-catalog": "cura"
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,6 @@
|
||||||
"author": "Ultimaker B.V.",
|
"author": "Ultimaker B.V.",
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"description": "Provides support for exporting Cura profiles.",
|
"description": "Provides support for exporting Cura profiles.",
|
||||||
"api": "7.4.0",
|
"api": 7,
|
||||||
"i18n-catalog":"cura"
|
"i18n-catalog":"cura"
|
||||||
}
|
}
|
||||||
|
|
17
plugins/DigitalLibrary/__init__.py
Normal file
17
plugins/DigitalLibrary/__init__.py
Normal 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)
|
||||||
|
}
|
8
plugins/DigitalLibrary/plugin.json
Normal file
8
plugins/DigitalLibrary/plugin.json
Normal 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,
|
||||||
|
"i18n-catalog": "cura"
|
||||||
|
}
|
6
plugins/DigitalLibrary/resources/images/arrow_down.svg
Normal file
6
plugins/DigitalLibrary/resources/images/arrow_down.svg
Normal 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 |
3
plugins/DigitalLibrary/resources/images/placeholder.svg
Normal file
3
plugins/DigitalLibrary/resources/images/placeholder.svg
Normal 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 |
|
@ -0,0 +1,52 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 378.13 348.13" version="1.1">
|
||||||
|
<defs
|
||||||
|
id="defs7">
|
||||||
|
<style
|
||||||
|
id="style2">
|
||||||
|
.cls-2,.cls-6{fill:#c5dbfb;}
|
||||||
|
.cls-6,.cls-7{stroke-width:2px;}
|
||||||
|
.cls-7{fill:#f3f8fe;}
|
||||||
|
.cls-6,.cls-7{stroke:#061884;}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-2" d="M43,17V3H83a2,2,0,0,1,2,2V17Z" />
|
||||||
|
<path fill="white" d="M 3 1 C 1.8954305 1 1 1.8954305 1 3 L 1 67 C 1 68.104569 1.8954305 69 3 69 L 72.152344 69 A 100 100 0 0 1 89 49.873047 L 89 19 C 89 17.895431 88.104569 17 87 17 L 56 17 L 40 1 L 3 1 z " />
|
||||||
|
<path fill="#c5dbfb" d="M 3 0 C 1.3549904 0 0 1.3549904 0 3 L 0 67 C 0 68.64501 1.3549904 70 3 70 L 71.484375 70 A 100 100 0 0 1 72.835938 68 L 3 68 C 2.4358706 68 2 67.564129 2 67 L 2 3 C 2 2.4358706 2.4358706 2 3 2 L 39.585938 2 L 55.585938 18 L 87 18 C 87.564129 18 88 18.435871 88 19 L 88 50.765625 A 100 100 0 0 1 90 49.007812 L 90 19 C 90 17.35499 88.64501 16 87 16 L 56.414062 16 L 40.414062 0 L 3 0 z " />
|
||||||
|
<path class="cls-2" d="M153,17V3h40a2,2,0,0,1,2,2V17Z" />
|
||||||
|
<path fill="white" d="M 113 1 C 111.89543 1 111 1.8954305 111 3 L 111 35.201172 A 100 100 0 0 1 155 25 A 100 100 0 0 1 199 35.201172 L 199 19 C 199 17.895431 198.10457 17 197 17 L 166 17 L 150 1 L 113 1 z " />
|
||||||
|
<path fill="#c5dbfb" d="M 113 0 C 111.35499 0 110 1.3549904 110 3 L 110 35.699219 A 100 100 0 0 1 112 34.716797 L 112 3 C 112 2.4358706 112.43587 2 113 2 L 149.58594 2 L 165.58594 18 L 197 18 C 197.56413 18 198 18.435871 198 19 L 198 34.716797 A 100 100 0 0 1 200 35.699219 L 200 19 C 200 17.35499 198.64501 16 197 16 L 166.41406 16 L 150.41406 0 L 113 0 z " />
|
||||||
|
<path class="cls-2" d="M263,17V3h40a2,2,0,0,1,2,2V17Z" />
|
||||||
|
<path fill="white" d="M 223 1 C 221.89543 1 221 1.8954305 221 3 L 221 49.875 A 100 100 0 0 1 237.84961 69 L 307 69 C 308.10457 69 309 68.104569 309 67 L 309 19 C 309 17.895431 308.10457 17 307 17 L 276 17 L 260 1 L 223 1 z " />
|
||||||
|
<path fill="#c5dbfb" d="M 223 0 C 221.35499 0 220 1.3549904 220 3 L 220 49.005859 A 100 100 0 0 1 222 50.765625 L 222 3 C 222 2.4358706 222.43587 2 223 2 L 259.58594 2 L 275.58594 18 L 307 18 C 307.56413 18 308 18.435871 308 19 L 308 67 C 308 67.564129 307.56413 68 307 68 L 237.16406 68 A 100 100 0 0 1 238.51562 70 L 307 70 C 308.64501 70 310 68.64501 310 67 L 310 19 C 310 17.35499 308.64501 16 307 16 L 276.41406 16 L 260.41406 0 L 223 0 z " />
|
||||||
|
<path fill="#c5dbfb" d="M 43 93 L 43 107 L 56.634766 107 A 100 100 0 0 1 60.259766 93 L 43 93 z " />
|
||||||
|
<path fill="white" d="M 3 91 C 1.8954305 91 1 91.895431 1 93 L 1 157 C 1 158.10457 1.8954305 159 3 159 L 60.958984 159 A 100 100 0 0 1 55 125 A 100 100 0 0 1 56.634766 107 L 56 107 L 40 91 L 3 91 z " />
|
||||||
|
<path fill="#c5dbfb" d="M 3 90 C 1.3549904 90 0 91.35499 0 93 L 0 157 C 0 158.64501 1.3549904 160 3 160 L 61.324219 160 A 100 100 0 0 1 60.603516 158 L 3 158 C 2.4358706 158 2 157.56413 2 157 L 2 93 C 2 92.435871 2.4358706 92 3 92 L 39.585938 92 L 55.585938 108 L 56.455078 108 A 100 100 0 0 1 56.822266 106 L 56.414062 106 L 40.414062 90 L 3 90 z " />
|
||||||
|
<path class="cls-2" d="M263,107V93h40a2,2,0,0,1,2,2v12Z" />
|
||||||
|
<path fill="white" d="M 249.04102 91 A 100 100 0 0 1 255 125 A 100 100 0 0 1 249.04102 159 L 307 159 C 308.10457 159 309 158.10457 309 157 L 309 109 C 309 107.89543 308.10457 107 307 107 L 276 107 L 260 91 L 249.04102 91 z " />
|
||||||
|
<path fill="#c5dbfb" d="M 248.67578 90 A 100 100 0 0 1 249.39648 92 L 259.58594 92 L 275.58594 108 L 307 108 C 307.56413 108 308 108.43587 308 109 L 308 157 C 308 157.56413 307.56413 158 307 158 L 249.39844 158 A 100 100 0 0 1 248.67383 160 L 307 160 C 308.64501 160 310 158.64501 310 157 L 310 109 C 310 107.35499 308.64501 106 307 106 L 276.41406 106 L 260.41406 90 L 248.67578 90 z " />
|
||||||
|
<path fill="#c5dbfb" d="M 43 183 L 43 197 L 85 197 L 85 196.41406 A 100 100 0 0 1 73.539062 183 L 43 183 z " />
|
||||||
|
<path fill="white" d="M 3 181 C 1.8954305 181 1 181.89543 1 183 L 1 247 C 1 248.10457 1.8954305 249 3 249 L 87 249 C 88.104569 249 89 248.10457 89 247 L 89 200.125 A 100 100 0 0 1 85.603516 197 L 56 197 L 40 181 L 3 181 z " />
|
||||||
|
<path fill="#c5dbfb" d="M 3 180 C 1.3549904 180 0 181.35499 0 183 L 0 247 C 0 248.64501 1.3549904 250 3 250 L 87 250 C 88.64501 250 90 248.64501 90 247 L 90 200.99414 A 100 100 0 0 1 88 199.23438 L 88 247 C 88 247.56413 87.564129 248 87 248 L 3 248 C 2.4358706 248 2 247.56413 2 247 L 2 183 C 2 182.43587 2.4358706 182 3 182 L 39.585938 182 L 55.585938 198 L 86.65625 198 A 100 100 0 0 1 84.580078 196 L 56.414062 196 L 40.414062 180 L 3 180 z " />
|
||||||
|
<path fill="white" d="M 111 214.79883 L 111 247 C 111 248.10457 111.89543 249 113 249 L 197 249 C 198.10457 249 199 248.10457 199 247 L 199 214.79883 A 100 100 0 0 1 155 225 A 100 100 0 0 1 111 214.79883 z " />
|
||||||
|
<path fill="#c5dbfb" d="M 110 214.30078 L 110 247 C 110 248.64501 111.35499 250 113 250 L 197 250 C 198.64501 250 200 248.64501 200 247 L 200 214.30078 A 100 100 0 0 1 198 215.2832 L 198 247 C 198 247.56413 197.56413 248 197 248 L 113 248 C 112.43587 248 112 247.56413 112 247 L 112 215.2832 A 100 100 0 0 1 110 214.30078 z " />
|
||||||
|
<path class="cls-2" d="M263,197V183h40a2,2,0,0,1,2,2v12Z" />
|
||||||
|
<path fill="white" d="M 237.84766 181 A 100 100 0 0 1 221 200.12695 L 221 247 C 221 248.10457 221.89543 249 223 249 L 307 249 C 308.10457 249 309 248.10457 309 247 L 309 199 C 309 197.89543 308.10457 197 307 197 L 276 197 L 260 181 L 237.84766 181 z " />
|
||||||
|
<path fill="#c5dbfb" d="M 238.51562 180 A 100 100 0 0 1 237.16406 182 L 259.58594 182 L 275.58594 198 L 307 198 C 307.56413 198 308 198.43587 308 199 L 308 247 C 308 247.56413 307.56413 248 307 248 L 223 248 C 222.43587 248 222 247.56413 222 247 L 222 199.23438 A 100 100 0 0 1 220 200.99219 L 220 247 C 220 248.64501 221.35499 250 223 250 L 307 250 C 308.64501 250 310 248.64501 310 247 L 310 199 C 310 197.35499 308.64501 196 307 196 L 276.41406 196 L 260.41406 180 L 238.51562 180 z " />
|
||||||
|
<path class="cls-6" d="M351.12,322.62h20a10,10,0,0,1,10,10v7a0,0,0,0,1,0,0h-40a0,0,0,0,1,0,0v-7A10,10,0,0,1,351.12,322.62Z" transform="translate(850.61 309.91) rotate(135)" />
|
||||||
|
<rect class="cls-7" x="293.75" y="225.25" width="40" height="117" transform="translate(-108.74 304.96) rotate(-45)" />
|
||||||
|
<polyline class="cls-7" points="213.69 199.25 252.58 238.14 267.43 223.29 228.54 184.4" />
|
||||||
|
<path fill="white" stroke="#061884" stroke-width="2px" d="M 154.94141 30 A 95 95 0 0 0 60 125 A 95 95 0 0 0 155 220 A 95 95 0 0 0 250 125 A 95 95 0 0 0 155 30 A 95 95 0 0 0 154.94141 30 z M 154.82812 40 A 85 85 0 0 1 155 40 A 85 85 0 0 1 240 125 A 85 85 0 0 1 155 210 A 85 85 0 0 1 70 125 A 85 85 0 0 1 154.82812 40 z " />
|
||||||
|
<path class="cls-6" d="M256.37,227.87h20a10,10,0,0,1,10,10v7a0,0,0,0,1,0,0h-40a0,0,0,0,1,0,0v-7a10,10,0,0,1,10-10Z" transform="translate(-89.12 257.58) rotate(-45)" />
|
||||||
|
<path fill="white" d="M 154.94141 45 A 80 80 0 0 0 111 58.185547 L 111 67 C 111 68.104569 111.89543 69 113 69 L 197 69 C 198.10457 69 199 68.104569 199 67 L 199 58.1875 A 80 80 0 0 0 155 45 A 80 80 0 0 0 154.94141 45 z " />
|
||||||
|
<path fill="#061884" d="M 112 57.539062 A 80 80 0 0 0 110 58.857422 L 110 67 C 110 68.64501 111.35499 70 113 70 L 197 70 C 198.64501 70 200 68.64501 200 67 L 200 58.857422 A 80 80 0 0 0 198 57.541016 L 198 67 C 198 67.564129 197.56413 68 197 68 L 113 68 C 112.43587 68 112 67.564129 112 67 L 112 57.539062 z " />
|
||||||
|
<path fill="#196ef0" d="M 81.679688 93 A 80 80 0 0 0 77.050781 107 L 85 107 L 85 95 A 2 2 0 0 0 83 93 L 81.679688 93 z " />
|
||||||
|
<path fill="white" d="M 77.050781 107 A 80 80 0 0 0 75 125 A 80 80 0 0 0 82.585938 159 L 87 159 C 88.104569 159 89 158.10457 89 157 L 89 109 C 89 107.89543 88.104569 107 87 107 L 77.050781 107 z " />
|
||||||
|
<path fill="#061884" d="M 77.289062 106 A 80 80 0 0 0 76.828125 108 L 87 108 C 87.564129 108 88 108.43587 88 109 L 88 157 C 88 157.56413 87.564129 158 87 158 L 82.125 158 A 80 80 0 0 0 83.0625 160 L 87 160 C 88.64501 160 90 158.64501 90 157 L 90 109 C 90 107.35499 88.64501 106 87 106 L 77.289062 106 z " />
|
||||||
|
<path fill="white" d="M 223 91 C 221.89543 91 221 91.895431 221 93 L 221 157 C 221 158.10457 221.89543 159 223 159 L 227.41406 159 A 80 80 0 0 0 235 125 A 80 80 0 0 0 227.41406 91 L 223 91 z " />
|
||||||
|
<path fill="#061884" d="M 223 90 C 221.35499 90 220 91.35499 220 93 L 220 157 C 220 158.64501 221.35499 160 223 160 L 226.9375 160 A 80 80 0 0 0 227.87695 158 L 223 158 C 222.43587 158 222 157.56413 222 157 L 222 93 C 222 92.435871 222.43587 92 223 92 L 227.875 92 A 80 80 0 0 0 226.9375 90 L 223 90 z " />
|
||||||
|
<path fill="#196ef0" d="M 153 183 L 153 197 L 189.86914 197 A 80 80 0 0 0 195 194.28125 L 195 185 A 2 2 0 0 0 193 183 L 153 183 z "/>
|
||||||
|
<path fill="white" d="M 113 181 C 111.89543 181 111 181.89543 111 183 L 111 191.8125 A 80 80 0 0 0 155 205 A 80 80 0 0 0 189.86914 197 L 166 197 L 150 181 L 113 181 z " />
|
||||||
|
<path fill="#061884" d="M 113 180 C 111.35499 180 110 181.35499 110 183 L 110 191.14258 A 80 80 0 0 0 112 192.45898 L 112 183 C 112 182.43587 112.43587 182 113 182 L 149.58594 182 L 165.58594 198 L 187.72461 198 A 80 80 0 0 0 191.86328 196 L 166.41406 196 L 150.41406 180 L 113 180 z " />
|
||||||
|
<path fill="#061884" d="m 149.18,133.69 v -3.48 a 14.36,14.36 0 0 1 1.74,-7.25 20.17,20.17 0 0 1 6.4,-6.17 25.87,25.87 0 0 0 5.68,-4.79 7,7 0 0 0 1.48,-4.34 4.13,4.13 0 0 0 -1.93,-3.62 9,9 0 0 0 -5.14,-1.3 24.94,24.94 0 0 0 -7.34,1.16 45.2,45.2 0 0 0 -7.78,3.31 l -5.37,-10.64 a 48.41,48.41 0 0 1 9.89,-4.21 40.25,40.25 0 0 1 11.67,-1.61 q 9.57,0 14.9,4.43 a 14.16,14.16 0 0 1 5.32,11.41 15.41,15.41 0 0 1 -2.55,9 30.38,30.38 0 0 1 -7.92,7.34 32.11,32.11 0 0 0 -5.23,4.37 5.91,5.91 0 0 0 -1.34,4 v 2.41 z m -1.61,15.12 q 0,-4.38 2.46,-6.12 a 10,10 0 0 1 5.95,-1.75 9.69,9.69 0 0 1 5.77,1.75 q 2.46,1.74 2.46,6.12 0,4.22 -2.46,6 a 9.42,9.42 0 0 1 -5.77,1.84 9.69,9.69 0 0 1 -5.95,-1.84 q -2.46,-1.81 -2.46,-6 z" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 9.6 KiB |
9
plugins/DigitalLibrary/resources/images/update.svg
Normal file
9
plugins/DigitalLibrary/resources/images/update.svg
Normal 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 |
159
plugins/DigitalLibrary/resources/qml/CreateNewProjectPopup.qml
Normal file
159
plugins/DigitalLibrary/resources/qml/CreateNewProjectPopup.qml
Normal 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,99}$/
|
||||||
|
}
|
||||||
|
|
||||||
|
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.length >= 2 && !busy
|
||||||
|
|
||||||
|
onClicked:
|
||||||
|
{
|
||||||
|
manager.createLibraryProjectAndSetAsPreselected(newProjectNameTextField.text)
|
||||||
|
}
|
||||||
|
busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
129
plugins/DigitalLibrary/resources/qml/LoadMoreProjectsCard.qml
Normal file
129
plugins/DigitalLibrary/resources/qml/LoadMoreProjectsCard.qml
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
203
plugins/DigitalLibrary/resources/qml/OpenProjectFilesPage.qml
Normal file
203
plugins/DigitalLibrary/resources/qml/OpenProjectFilesPage.qml
Normal file
|
@ -0,0 +1,203 @@
|
||||||
|
// 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
|
||||||
|
onDoubleClicked:
|
||||||
|
{
|
||||||
|
manager.setSelectedFileIndices([row]);
|
||||||
|
openFilesButton.clicked();
|
||||||
|
}
|
||||||
|
|
||||||
|
OldControls.TableViewColumn
|
||||||
|
{
|
||||||
|
id: fileNameColumn
|
||||||
|
role: "fileName"
|
||||||
|
title: "Name"
|
||||||
|
width: Math.round(filesTableView.width / 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
OldControls.TableViewColumn
|
||||||
|
{
|
||||||
|
id: usernameColumn
|
||||||
|
role: "username"
|
||||||
|
title: "Uploaded by"
|
||||||
|
width: Math.round(filesTableView.width / 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
OldControls.TableViewColumn
|
||||||
|
{
|
||||||
|
role: "uploadedAt"
|
||||||
|
title: "Uploaded at"
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections
|
||||||
|
{
|
||||||
|
target: filesTableView.selection
|
||||||
|
function onSelectionChanged()
|
||||||
|
{
|
||||||
|
let newSelection = [];
|
||||||
|
filesTableView.selection.forEach(function(rowIndex) { newSelection.push(rowIndex); });
|
||||||
|
manager.setSelectedFileIndices(newSelection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
92
plugins/DigitalLibrary/resources/qml/ProjectSummaryCard.qml
Normal file
92
plugins/DigitalLibrary/resources/qml/ProjectSummaryCard.qml
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
259
plugins/DigitalLibrary/resources/qml/SaveProjectFilesPage.qml
Normal file
259
plugins/DigitalLibrary/resources/qml/SaveProjectFilesPage.qml
Normal 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: /^[\w\-\. ()]{0,255}$/
|
||||||
|
}
|
||||||
|
|
||||||
|
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.length >= 1
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
231
plugins/DigitalLibrary/resources/qml/SelectProjectPage.qml
Normal file
231
plugins/DigitalLibrary/resources/qml/SelectProjectPage.qml
Normal file
|
@ -0,0 +1,231 @@
|
||||||
|
// Copyright (C) 2021 Ultimaker B.V.
|
||||||
|
// Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
|
import QtQuick 2.10
|
||||||
|
import QtQuick.Window 2.2
|
||||||
|
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
|
||||||
|
import QtQuick.Controls 2.3
|
||||||
|
import QtQuick.Controls.Styles 1.4
|
||||||
|
import QtQuick.Layouts 1.1
|
||||||
|
|
||||||
|
import UM 1.2 as UM
|
||||||
|
import Cura 1.6 as Cura
|
||||||
|
|
||||||
|
import DigitalFactory 1.0 as DF
|
||||||
|
|
||||||
|
|
||||||
|
Item
|
||||||
|
{
|
||||||
|
id: base
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
height: parent.height
|
||||||
|
property bool createNewProjectButtonVisible: true
|
||||||
|
|
||||||
|
anchors
|
||||||
|
{
|
||||||
|
top: parent.top
|
||||||
|
bottom: parent.bottom
|
||||||
|
left: parent.left
|
||||||
|
right: parent.right
|
||||||
|
margins: UM.Theme.getSize("default_margin").width
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout
|
||||||
|
{
|
||||||
|
id: headerRow
|
||||||
|
|
||||||
|
anchors
|
||||||
|
{
|
||||||
|
top: parent.top
|
||||||
|
left: parent.left
|
||||||
|
right: parent.right
|
||||||
|
}
|
||||||
|
height: childrenRect.height
|
||||||
|
spacing: UM.Theme.getSize("default_margin").width
|
||||||
|
|
||||||
|
Cura.TextField
|
||||||
|
{
|
||||||
|
id: searchBar
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: createNewProjectButton.height
|
||||||
|
|
||||||
|
onTextEdited: manager.projectFilter = text //Update the search filter when editing this text field.
|
||||||
|
|
||||||
|
leftIcon: UM.Theme.getIcon("Magnifier")
|
||||||
|
placeholderText: "Search"
|
||||||
|
}
|
||||||
|
|
||||||
|
Cura.SecondaryButton
|
||||||
|
{
|
||||||
|
id: createNewProjectButton
|
||||||
|
|
||||||
|
text: "New Library project"
|
||||||
|
visible: createNewProjectButtonVisible && manager.userAccountCanCreateNewLibraryProject && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
|
||||||
|
|
||||||
|
onClicked:
|
||||||
|
{
|
||||||
|
createNewProjectPopup.open()
|
||||||
|
}
|
||||||
|
busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Cura.SecondaryButton
|
||||||
|
{
|
||||||
|
id: upgradePlanButton
|
||||||
|
|
||||||
|
text: "Upgrade plan"
|
||||||
|
iconSource: UM.Theme.getIcon("LinkExternal")
|
||||||
|
visible: createNewProjectButtonVisible && !manager.userAccountCanCreateNewLibraryProject && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
|
||||||
|
tooltip: "You have reached the maximum number of projects allowed by your subscription. Please upgrade to the Professional subscription to create more projects."
|
||||||
|
tooltipWidth: parent.width * 0.5
|
||||||
|
|
||||||
|
onClicked: Qt.openUrlExternally("https://ultimaker.com/software/ultimaker-essentials/sign-up-cura?utm_source=cura&utm_medium=software&utm_campaign=lib-max")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: searchBar.text === "" ? "../images/digital_factory.svg" : "../images/projects_not_found.svg"
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
width: parent.width - 2 * UM.Theme.getSize("thick_margin").width
|
||||||
|
}
|
||||||
|
|
||||||
|
Label
|
||||||
|
{
|
||||||
|
id: noLibraryProjectsLabel
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: searchBar.text === "" ? "It appears that you don't have any projects in the Library yet." : "No projects found that match the search query."
|
||||||
|
font: UM.Theme.getFont("medium")
|
||||||
|
color: UM.Theme.getColor("text")
|
||||||
|
}
|
||||||
|
|
||||||
|
Cura.TertiaryButton
|
||||||
|
{
|
||||||
|
id: visitDigitalLibraryButton
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: "Visit Digital Library"
|
||||||
|
onClicked: Qt.openUrlExternally(CuraApplication.ultimakerDigitalFactoryUrl + "/app/library")
|
||||||
|
visible: searchBar.text === "" //Show the link to Digital Library when there are no projects in the user's Library.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item
|
||||||
|
{
|
||||||
|
id: projectListContainer
|
||||||
|
anchors
|
||||||
|
{
|
||||||
|
top: headerRow.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)
|
||||||
|
}
|
||||||
|
}
|
74
plugins/DigitalLibrary/src/BaseModel.py
Normal file
74
plugins/DigitalLibrary/src/BaseModel.py
Normal 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)
|
31
plugins/DigitalLibrary/src/CloudError.py
Normal file
31
plugins/DigitalLibrary/src/CloudError.py
Normal 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)
|
373
plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py
Normal file
373
plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py
Normal 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
|
149
plugins/DigitalLibrary/src/DFFileUploader.py
Normal file
149
plugins/DigitalLibrary/src/DFFileUploader.py
Normal 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()
|
16
plugins/DigitalLibrary/src/DFLibraryFileUploadRequest.py
Normal file
16
plugins/DigitalLibrary/src/DFLibraryFileUploadRequest.py
Normal 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)
|
49
plugins/DigitalLibrary/src/DFLibraryFileUploadResponse.py
Normal file
49
plugins/DigitalLibrary/src/DFLibraryFileUploadResponse.py
Normal 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)
|
21
plugins/DigitalLibrary/src/DFPrintJobUploadRequest.py
Normal file
21
plugins/DigitalLibrary/src/DFPrintJobUploadRequest.py
Normal 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)
|
35
plugins/DigitalLibrary/src/DFPrintJobUploadResponse.py
Normal file
35
plugins/DigitalLibrary/src/DFPrintJobUploadResponse.py
Normal 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)
|
381
plugins/DigitalLibrary/src/DigitalFactoryApiClient.py
Normal file
381
plugins/DigitalLibrary/src/DigitalFactoryApiClient.py
Normal file
|
@ -0,0 +1,381 @@
|
||||||
|
# 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 .DigitalFactoryFeatureBudgetResponse import DigitalFactoryFeatureBudgetResponse
|
||||||
|
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._library_max_private_projects: Optional[int] = None
|
||||||
|
|
||||||
|
self._projects_pagination_mgr = PaginationManager(limit = projects_limit_per_page) if projects_limit_per_page else None # type: Optional[PaginationManager]
|
||||||
|
|
||||||
|
def checkUserHasAccess(self, callback: Callable) -> None:
|
||||||
|
"""Checks if the user has any sort of access to the digital library.
|
||||||
|
A user is considered to have access if the max-# of private projects is greater then 0 (or -1 for unlimited).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def callbackWrap(response: Optional[Any] = None, *args, **kwargs) -> None:
|
||||||
|
if (response is not None and isinstance(response, DigitalFactoryFeatureBudgetResponse) and
|
||||||
|
response.library_max_private_projects is not None):
|
||||||
|
callback(
|
||||||
|
response.library_max_private_projects == -1 or # Note: -1 is unlimited
|
||||||
|
response.library_max_private_projects > 0)
|
||||||
|
self._library_max_private_projects = response.library_max_private_projects
|
||||||
|
else:
|
||||||
|
Logger.warning(f"Digital Factory: Response is not a feature budget, likely an error: {str(response)}")
|
||||||
|
callback(False)
|
||||||
|
|
||||||
|
self._http.get(f"{self.CURA_API_ROOT}/feature_budgets",
|
||||||
|
scope = self._scope,
|
||||||
|
callback = self._parseCallback(callbackWrap, DigitalFactoryFeatureBudgetResponse, callbackWrap),
|
||||||
|
error_callback = callbackWrap,
|
||||||
|
timeout = self.DEFAULT_REQUEST_TIMEOUT)
|
||||||
|
|
||||||
|
def checkUserCanCreateNewLibraryProject(self, callback: Callable) -> None:
|
||||||
|
"""
|
||||||
|
Checks if the user is allowed to create new library projects.
|
||||||
|
A user is allowed to create new library projects if the haven't reached their maximum allowed private projects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def callbackWrap(response: Optional[Any] = None, *args, **kwargs) -> None:
|
||||||
|
if response is not None:
|
||||||
|
if isinstance(response, DigitalFactoryProjectResponse): # The user has only one private project
|
||||||
|
callback(True)
|
||||||
|
elif isinstance(response, list) and all(isinstance(r, DigitalFactoryProjectResponse) for r in response):
|
||||||
|
callback(len(response) < cast(int, self._library_max_private_projects))
|
||||||
|
else:
|
||||||
|
Logger.warning(f"Digital Factory: Incorrect response type received when requesting private projects: {str(response)}")
|
||||||
|
callback(False)
|
||||||
|
else:
|
||||||
|
Logger.warning(f"Digital Factory: Response is empty, likely an error: {str(response)}")
|
||||||
|
callback(False)
|
||||||
|
|
||||||
|
if self._library_max_private_projects is not None and self._library_max_private_projects > 0:
|
||||||
|
# The user has a limit in the number of private projects they can create. Check whether they have already
|
||||||
|
# reached that limit.
|
||||||
|
# Note: Set the pagination manager to None when doing this get request, or else the next/previous links
|
||||||
|
# of the pagination will become corrupted
|
||||||
|
url = f"{self.CURA_API_ROOT}/projects?shared=false&limit={self._library_max_private_projects}"
|
||||||
|
self._http.get(url,
|
||||||
|
scope = self._scope,
|
||||||
|
callback = self._parseCallback(callbackWrap, DigitalFactoryProjectResponse, callbackWrap, pagination_manager = None),
|
||||||
|
error_callback = callbackWrap,
|
||||||
|
timeout = self.DEFAULT_REQUEST_TIMEOUT)
|
||||||
|
else:
|
||||||
|
# If the limit is -1, then the user is allowed unlimited projects. If its 0 then they are not allowed to
|
||||||
|
# create any projects
|
||||||
|
callback(self._library_max_private_projects == -1)
|
||||||
|
|
||||||
|
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, search_filter: str, 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 search_filter: Text to filter the search results. If given an empty string, results are not filtered.
|
||||||
|
: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 = f"{self.CURA_API_ROOT}/projects"
|
||||||
|
query_character = "?"
|
||||||
|
if self._projects_pagination_mgr:
|
||||||
|
self._projects_pagination_mgr.reset() # reset to clear all the links and response metadata
|
||||||
|
url += f"{query_character}limit={self._projects_pagination_mgr.limit}"
|
||||||
|
query_character = "&"
|
||||||
|
if search_filter != "":
|
||||||
|
url += f"{query_character}search={search_filter}"
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
Logger.log("i", "Attempt to create new DF project '{}'.".format(project_name))
|
||||||
|
|
||||||
|
url = "{}/projects".format(self.CURA_API_ROOT)
|
||||||
|
data = json.dumps({"data": {"display_name": project_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()
|
620
plugins/DigitalLibrary/src/DigitalFactoryController.py
Normal file
620
plugins/DigitalLibrary/src/DigitalFactoryController.py
Normal file
|
@ -0,0 +1,620 @@
|
||||||
|
# Copyright (c) 2021 Ultimaker B.V.
|
||||||
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
|
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, QTimer, 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()
|
||||||
|
|
||||||
|
"""Signal to inform about the state of user access."""
|
||||||
|
userAccessStateChanged = pyqtSignal(bool)
|
||||||
|
|
||||||
|
"""Signal to inform whether the user is allowed to create more Library projects."""
|
||||||
|
userCanCreateNewLibraryProjectChanged = pyqtSignal(bool)
|
||||||
|
|
||||||
|
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._account.loginStateChanged.connect(self._onLoginStateChanged)
|
||||||
|
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."
|
||||||
|
self._project_filter = ""
|
||||||
|
self._project_filter_change_timer = QTimer()
|
||||||
|
self._project_filter_change_timer.setInterval(200)
|
||||||
|
self._project_filter_change_timer.setSingleShot(True)
|
||||||
|
self._project_filter_change_timer.timeout.connect(self._applyProjectFilter)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
self._user_has_access = False
|
||||||
|
self._user_account_can_create_new_project = False
|
||||||
|
|
||||||
|
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 _onLoginStateChanged(self, logged_in: bool) -> None:
|
||||||
|
def callback(has_access, **kwargs):
|
||||||
|
self._user_has_access = has_access
|
||||||
|
self.userAccessStateChanged.emit(logged_in)
|
||||||
|
|
||||||
|
self._api.checkUserHasAccess(callback)
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
if self._user_has_access:
|
||||||
|
self._api.checkUserCanCreateNewLibraryProject(callback = self.setCanCreateNewLibraryProject)
|
||||||
|
return self._user_has_access
|
||||||
|
|
||||||
|
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(search_filter = self._project_filter, 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)
|
||||||
|
|
||||||
|
def setProjectFilter(self, new_filter: str) -> None:
|
||||||
|
"""
|
||||||
|
Called when the user wants to change the search filter for projects.
|
||||||
|
|
||||||
|
The filter is not immediately applied. There is some delay to allow the user to finish typing.
|
||||||
|
:param new_filter: The new filter that the user wants to apply.
|
||||||
|
"""
|
||||||
|
self._project_filter = new_filter
|
||||||
|
self._project_filter_change_timer.start()
|
||||||
|
|
||||||
|
"""
|
||||||
|
Signal to notify Qt that the applied filter has changed.
|
||||||
|
"""
|
||||||
|
projectFilterChanged = pyqtSignal()
|
||||||
|
|
||||||
|
@pyqtProperty(str, notify = projectFilterChanged, fset = setProjectFilter)
|
||||||
|
def projectFilter(self) -> str:
|
||||||
|
"""
|
||||||
|
The current search filter being applied to the project list.
|
||||||
|
:return: The current search filter being applied to the project list.
|
||||||
|
"""
|
||||||
|
return self._project_filter
|
||||||
|
|
||||||
|
def _applyProjectFilter(self) -> None:
|
||||||
|
"""
|
||||||
|
Actually apply the current filter to search for projects with the user-defined search string.
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
self.clear()
|
||||||
|
self.projectFilterChanged.emit()
|
||||||
|
self._api.getProjectsFirstPage(search_filter = self._project_filter, on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|
||||||
|
# Although Cura supports these, it's super confusing in this context to show them.
|
||||||
|
for extension in ["jpg", "jpeg", "png", "bmp", "gif"]:
|
||||||
|
if extension in self._supported_file_types:
|
||||||
|
del self._supported_file_types[extension]
|
||||||
|
|
||||||
|
@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(search_filter = self._project_filter, on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
|
||||||
|
self._api.checkUserCanCreateNewLibraryProject(callback = self.setCanCreateNewLibraryProject)
|
||||||
|
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
|
||||||
|
|
||||||
|
def setCanCreateNewLibraryProject(self, can_create_new_library_project: bool) -> None:
|
||||||
|
self._user_account_can_create_new_project = can_create_new_library_project
|
||||||
|
self.userCanCreateNewLibraryProjectChanged.emit(self._user_account_can_create_new_project)
|
||||||
|
|
||||||
|
@pyqtProperty(bool, fset = setCanCreateNewLibraryProject, notify = userCanCreateNewLibraryProjectChanged)
|
||||||
|
def userAccountCanCreateNewLibraryProject(self) -> bool:
|
||||||
|
return self._user_account_can_create_new_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
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Copyright (c) 2021 Ultimaker B.V.
|
||||||
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
|
from .BaseModel import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class DigitalFactoryFeatureBudgetResponse(BaseModel):
|
||||||
|
"""Class representing the capabilities of a user account for Digital Library.
|
||||||
|
NOTE: For each max_..._projects fields, '-1' means unlimited!
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
library_can_use_business_value: Optional[bool] = False,
|
||||||
|
library_can_use_comments: Optional[bool] = False,
|
||||||
|
library_can_use_status: Optional[bool] = False,
|
||||||
|
library_can_use_tags: Optional[bool] = False,
|
||||||
|
library_can_use_technical_requirements: Optional[bool] = False,
|
||||||
|
library_max_organization_shared_projects: Optional[int] = None, # -1 means unlimited
|
||||||
|
library_max_private_projects: Optional[int] = None, # -1 means unlimited
|
||||||
|
library_max_team_shared_projects: Optional[int] = None, # -1 means unlimited
|
||||||
|
**kwargs) -> None:
|
||||||
|
|
||||||
|
self.library_can_use_business_value = library_can_use_business_value
|
||||||
|
self.library_can_use_comments = library_can_use_comments
|
||||||
|
self.library_can_use_status = library_can_use_status
|
||||||
|
self.library_can_use_tags = library_can_use_tags
|
||||||
|
self.library_can_use_technical_requirements = library_can_use_technical_requirements
|
||||||
|
self.library_max_organization_shared_projects = library_max_organization_shared_projects # -1 means unlimited
|
||||||
|
self.library_max_private_projects = library_max_private_projects # -1 means unlimited
|
||||||
|
self.library_max_team_shared_projects = library_max_team_shared_projects # -1 means unlimited
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return "max private: {}, max org: {}, max team: {}".format(
|
||||||
|
self.library_max_private_projects,
|
||||||
|
self.library_max_organization_shared_projects,
|
||||||
|
self.library_max_team_shared_projects)
|
||||||
|
|
||||||
|
# Validates the model, raising an exception if the model is invalid.
|
||||||
|
def validate(self) -> None:
|
||||||
|
super().validate()
|
||||||
|
# No validation for now, as the response can be "data: []", which should be interpreted as all False and 0's
|
116
plugins/DigitalLibrary/src/DigitalFactoryFileModel.py
Normal file
116
plugins/DigitalLibrary/src/DigitalFactoryFileModel.py
Normal 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
|
62
plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py
Normal file
62
plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py
Normal 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._controller.userAccessStateChanged.connect(self._onUserAccessStateChanged)
|
||||||
|
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 _onUserAccessStateChanged(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()
|
57
plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py
Normal file
57
plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py
Normal 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")
|
118
plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py
Normal file
118
plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py
Normal 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._controller.userAccessStateChanged.connect(self._onUserAccessStateChanged)
|
||||||
|
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 _onUserAccessStateChanged(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)
|
|
@ -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")
|
64
plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py
Normal file
64
plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py
Normal 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()
|
65
plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py
Normal file
65
plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py
Normal 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")
|
55
plugins/DigitalLibrary/src/ExportFileJob.py
Normal file
55
plugins/DigitalLibrary/src/ExportFileJob.py
Normal 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()
|
30
plugins/DigitalLibrary/src/PaginationLinks.py
Normal file
30
plugins/DigitalLibrary/src/PaginationLinks.py
Normal 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)
|
43
plugins/DigitalLibrary/src/PaginationManager.py
Normal file
43
plugins/DigitalLibrary/src/PaginationManager.py
Normal 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
|
25
plugins/DigitalLibrary/src/PaginationMetadata.py
Normal file
25
plugins/DigitalLibrary/src/PaginationMetadata.py
Normal 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)
|
24
plugins/DigitalLibrary/src/ResponseMeta.py
Normal file
24
plugins/DigitalLibrary/src/ResponseMeta.py
Normal 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)
|
0
plugins/DigitalLibrary/src/__init__.py
Normal file
0
plugins/DigitalLibrary/src/__init__.py
Normal 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()
|
73
plugins/DigitalLibrary/tests/TestDigitalFactoryFileModel.py
Normal file
73
plugins/DigitalLibrary/tests/TestDigitalFactoryFileModel.py
Normal 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
|
|
@ -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"
|
89
plugins/DigitalLibrary/tests/TestDigitalLibraryApiClient.py
Normal file
89
plugins/DigitalLibrary/tests/TestDigitalLibraryApiClient.py
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
# Copyright (c) 2021 Ultimaker B.V.
|
||||||
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
|
|
||||||
|
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(search_filter = "filter", 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&search=filter"
|
||||||
|
|
||||||
|
# Change the limit & try again
|
||||||
|
http_manager.get.reset_mock()
|
||||||
|
pagination_manager.limit = 80
|
||||||
|
api_client.getProjectsFirstPage(search_filter = "filter", 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&search=filter"
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
5
plugins/DigitalLibrary/tests/conftest.py
Normal file
5
plugins/DigitalLibrary/tests/conftest.py
Normal 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__)), ".."))
|
|
@ -3,6 +3,6 @@
|
||||||
"author": "Ultimaker B.V.",
|
"author": "Ultimaker B.V.",
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"description": "Checks for firmware updates.",
|
"description": "Checks for firmware updates.",
|
||||||
"api": "7.4.0",
|
"api": 7,
|
||||||
"i18n-catalog": "cura"
|
"i18n-catalog": "cura"
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue