mirror of
https://github.com/Ultimaker/Cura.git
synced 2025-07-23 14:44:13 -06:00
Merge branch 'master' of https://github.com/Eryone/Cura
This commit is contained in:
commit
4c6b8a6e1d
5073 changed files with 105394 additions and 26081 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.
|
||||
title: ''
|
||||
labels: 'Type: Bug'
|
||||
|
|
28
.github/ISSUE_TEMPLATE/bugreport.yaml
vendored
28
.github/ISSUE_TEMPLATE/bugreport.yaml
vendored
|
@ -1,7 +1,6 @@
|
|||
name: Bug Report
|
||||
description: Create a report to help us fix issues.
|
||||
labels: "Type: Bug"
|
||||
issue_body: true
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
@ -15,7 +14,7 @@ body:
|
|||
attributes:
|
||||
label: Application Version
|
||||
description: The version of Cura this issue occurs with.
|
||||
placeholder: 4.8.0
|
||||
placeholder: 4.9.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
@ -56,13 +55,28 @@ body:
|
|||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Additional information & file uploads
|
||||
|
||||
Please be sure to add the following files:
|
||||
* For slicing issues, upload a **project file** that clearly shows the bug.
|
||||
To save a project file go to `File -> Save project`. Please make sure to .zip your project file. For big files you may need to use WeTransfer or similar file sharing sites.
|
||||
G-code files are not project files!
|
||||
* **Screenshots** of showing the problem, perhaps before/after images.
|
||||
* A **log file**, see [here](https://github.com/Ultimaker/Cura#logging-issues) how to find the log file.
|
||||
|
||||
You can add these files and additional information that is relevant to the issue in the comments below.
|
||||
* A **log file** for crashes and similar issues.
|
||||
You can find your log file here:
|
||||
Windows: `%APPDATA%\cura\<Cura version>\cura.log` or usually `C:\Users\\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
|
||||
MacOS: `$USER/Library/Application Support/cura/<Cura version>/cura.log`
|
||||
Ubuntu/Linux: `$USER/.local/share/cura/<Cura version>/cura.log`
|
||||
|
||||
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist of files to include
|
||||
options:
|
||||
- label: Log file
|
||||
- label: Project file
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information & file uploads
|
||||
description: You can add these files and additional information that is relevant to the issue in the comments below.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
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
|
||||
description: Suggest an idea for this project.
|
||||
labels: "Type: New Feature"
|
||||
issue_body: true
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
@ -28,7 +27,7 @@ body:
|
|||
- type: textarea
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered. Again, if possible, think about why these alternatives are not working out.
|
||||
description: A clear and concise description of any alternative solutions or features you've considered. If possible, think about why these alternatives are not working out.
|
||||
placeholder: The alternatives I've considered are...
|
||||
validations:
|
||||
required: true
|
||||
|
@ -39,8 +38,7 @@ body:
|
|||
placeholder: It will affect...
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
- type: textarea
|
||||
attributes:
|
||||
value: |
|
||||
## Additional information & file uploads
|
||||
You can add pictures or files to visualize your feature request in the comments below.
|
||||
label: Additional information & file uploads
|
||||
description: You can add pictures or files to visualize your feature request in the comments below.
|
11
CITATION.cff
Normal file
11
CITATION.cff
Normal file
|
@ -0,0 +1,11 @@
|
|||
# YAML 1.2
|
||||
---
|
||||
authors:
|
||||
cff-version: "1.1.0"
|
||||
date-released: 2021-06-28
|
||||
license: "LGPL-3.0"
|
||||
message: "If you use this software, please cite it using these metadata."
|
||||
repository-code: "https://github.com/ultimaker/cura/"
|
||||
title: "Ultimaker Cura"
|
||||
version: "4.10.0"
|
||||
...
|
|
@ -33,7 +33,7 @@ configure_file(${CMAKE_SOURCE_DIR}/com.ultimaker.cura.desktop.in ${CMAKE_BINARY_
|
|||
configure_file(cura/CuraVersion.py.in CuraVersion.py @ONLY)
|
||||
|
||||
|
||||
# FIXME: The new FindPython3 finds the system's Python3.6 reather than the Python3.5 that we built for Cura's environment.
|
||||
# FIXME: The new FindPython3 finds the system's Python3.6 rather than the Python3.5 that we built for Cura's environment.
|
||||
# So we're using the old method here, with FindPythonInterp for now.
|
||||
find_package(PythonInterp 3 REQUIRED)
|
||||
|
||||
|
|
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)
|
||||
* 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`
|
||||
* `$USER/Library/Application Support/cura/<Cura version>/cura.log` (OSX)
|
||||
* `$USER/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux)
|
||||
* `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
|
||||
* `$HOME/Library/Application Support/cura/<Cura version>/cura.log` (OSX)
|
||||
* `$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
|
||||
|
||||
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
|
||||
------------
|
||||
|
@ -26,10 +26,16 @@ Dependencies
|
|||
* [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.
|
||||
|
||||
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
|
||||
-------------
|
||||
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
|
||||
-------------
|
||||
Please check our [Wiki page](https://github.com/Ultimaker/Cura/wiki/Running-Cura-from-Source) for details about running Cura from source.
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
include(CTest)
|
||||
include(CMakeParseArguments)
|
||||
|
||||
# FIXME: The new FindPython3 finds the system's Python3.6 reather than the Python3.5 that we built for Cura's environment.
|
||||
# FIXME: The new FindPython3 finds the system's Python3.6 rather than the Python3.5 that we built for Cura's environment.
|
||||
# So we're using the old method here, with FindPythonInterp for now.
|
||||
find_package(PythonInterp 3 REQUIRED)
|
||||
|
||||
|
|
|
@ -28,6 +28,6 @@
|
|||
<image>https://raw.githubusercontent.com/Ultimaker/Cura/master/screenshot.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<url type="homepage">https://ultimaker.com/en/products/cura-software?utm_source=cura&utm_medium=software&utm_campaign=resources</url>
|
||||
<url type="homepage">https://ultimaker.com/software/ultimaker-cura?utm_source=cura&utm_medium=software&utm_campaign=cura-update-linux</url>
|
||||
<translation type="gettext">Cura</translation>
|
||||
</component>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, TYPE_CHECKING, Callable
|
||||
from typing import Any, Optional, Dict, TYPE_CHECKING, Callable
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS
|
||||
|
||||
|
@ -40,12 +40,15 @@ class Account(QObject):
|
|||
"""
|
||||
|
||||
# The interval in which sync services are automatically triggered
|
||||
SYNC_INTERVAL = 30.0 # seconds
|
||||
SYNC_INTERVAL = 60.0 # seconds
|
||||
Q_ENUMS(SyncState)
|
||||
|
||||
loginStateChanged = pyqtSignal(bool)
|
||||
"""Signal emitted when user logged in or out"""
|
||||
|
||||
additionalRightsChanged = pyqtSignal("QVariantMap")
|
||||
"""Signal emitted when a users additional rights change"""
|
||||
|
||||
accessTokenChanged = pyqtSignal()
|
||||
syncRequested = pyqtSignal()
|
||||
"""Sync services may connect to this signal to receive sync triggers.
|
||||
|
@ -58,6 +61,11 @@ class Account(QObject):
|
|||
manualSyncEnabledChanged = pyqtSignal(bool)
|
||||
updatePackagesEnabledChanged = pyqtSignal(bool)
|
||||
|
||||
CLIENT_SCOPES = "account.user.read drive.backup.read drive.backup.write packages.download " \
|
||||
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write " \
|
||||
"library.project.read library.project.write cura.printjob.read cura.printjob.write " \
|
||||
"cura.mesh.read cura.mesh.write"
|
||||
|
||||
def __init__(self, application: "CuraApplication", parent = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._application = application
|
||||
|
@ -65,6 +73,7 @@ class Account(QObject):
|
|||
|
||||
self._error_message = None # type: Optional[Message]
|
||||
self._logged_in = False
|
||||
self._additional_rights: Dict[str, Any] = {}
|
||||
self._sync_state = SyncState.IDLE
|
||||
self._manual_sync_enabled = False
|
||||
self._update_packages_enabled = False
|
||||
|
@ -79,10 +88,7 @@ class Account(QObject):
|
|||
CALLBACK_PORT=self._callback_port,
|
||||
CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port),
|
||||
CLIENT_ID="um----------------------------ultimaker_cura",
|
||||
CLIENT_SCOPES="account.user.read drive.backup.read drive.backup.write packages.download "
|
||||
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write "
|
||||
"library.project.read library.project.write cura.printjob.read cura.printjob.write "
|
||||
"cura.mesh.read cura.mesh.write",
|
||||
CLIENT_SCOPES=self.CLIENT_SCOPES,
|
||||
AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data",
|
||||
AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root),
|
||||
AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root)
|
||||
|
@ -107,7 +113,6 @@ class Account(QObject):
|
|||
self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged)
|
||||
self._authorization_service.loadAuthDataFromPreferences()
|
||||
|
||||
|
||||
@pyqtProperty(int, notify=syncStateChanged)
|
||||
def syncState(self):
|
||||
return self._sync_state
|
||||
|
@ -176,7 +181,10 @@ class Account(QObject):
|
|||
if error_message:
|
||||
if self._error_message:
|
||||
self._error_message.hide()
|
||||
self._error_message = Message(error_message, title = i18n_catalog.i18nc("@info:title", "Login failed"))
|
||||
Logger.log("w", "Failed to login: %s", error_message)
|
||||
self._error_message = Message(error_message,
|
||||
title = i18n_catalog.i18nc("@info:title", "Login failed"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
self._error_message.show()
|
||||
self._logged_in = False
|
||||
self.loginStateChanged.emit(False)
|
||||
|
@ -207,7 +215,7 @@ class Account(QObject):
|
|||
if self._update_timer.isActive():
|
||||
self._update_timer.stop()
|
||||
elif self._sync_state == SyncState.SYNCING:
|
||||
Logger.warning("Starting a new sync while previous sync was not completed\n{}", str(self._sync_services))
|
||||
Logger.debug("Starting a new sync while previous sync was not completed")
|
||||
|
||||
self.syncRequested.emit()
|
||||
|
||||
|
@ -297,3 +305,14 @@ class Account(QObject):
|
|||
return # Nothing to do, user isn't logged in.
|
||||
|
||||
self._authorization_service.deleteAuthData()
|
||||
|
||||
def updateAdditionalRight(self, **kwargs) -> None:
|
||||
"""Update the additional rights of the account.
|
||||
The argument(s) are the rights that need to be set"""
|
||||
self._additional_rights.update(kwargs)
|
||||
self.additionalRightsChanged.emit(self._additional_rights)
|
||||
|
||||
@pyqtProperty("QVariantMap", notify = additionalRightsChanged)
|
||||
def additionalRights(self) -> Dict[str, Any]:
|
||||
"""A dictionary which can be queried for additional account rights."""
|
||||
return self._additional_rights
|
||||
|
|
|
@ -13,7 +13,7 @@ DEFAULT_CURA_DEBUG_MODE = False
|
|||
# Each release has a fixed SDK version coupled with it. It doesn't make sense to make it configurable because, for
|
||||
# example Cura 3.2 with SDK version 6.1 will not work. So the SDK version is hard-coded here and left out of the
|
||||
# CuraVersion.py.in template.
|
||||
CuraSDKVersion = "7.4.0"
|
||||
CuraSDKVersion = "7.8.0"
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraAppName # type: ignore
|
||||
|
|
|
@ -147,6 +147,8 @@ class ArrangeObjectsAllBuildPlatesJob(Job):
|
|||
status_message.hide()
|
||||
|
||||
if not found_solution_for_all:
|
||||
no_full_solution_message = Message(i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"),
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"))
|
||||
no_full_solution_message = Message(i18n_catalog.i18nc("@info:status",
|
||||
"Unable to find a location within the build volume for all objects"),
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
no_full_solution_message.show()
|
||||
|
|
|
@ -39,6 +39,7 @@ class ArrangeObjectsJob(Job):
|
|||
no_full_solution_message = Message(
|
||||
i18n_catalog.i18nc("@info:status",
|
||||
"Unable to find a location within the build volume for all objects"),
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"))
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
no_full_solution_message.show()
|
||||
self.finished.emit(self)
|
||||
|
|
|
@ -36,6 +36,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
|
|||
found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
|
||||
node_items: A list of the nodes return by libnest2d, which contain the new positions on the buildplate
|
||||
"""
|
||||
spacing = int(1.5 * factor) # 1.5mm spacing.
|
||||
|
||||
machine_width = build_volume.getWidth()
|
||||
machine_depth = build_volume.getDepth()
|
||||
|
@ -75,7 +76,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
|
|||
# Clip the disallowed areas so that they don't overlap the bounding box (The arranger chokes otherwise)
|
||||
clipped_area = area.intersectionConvexHulls(build_plate_polygon)
|
||||
|
||||
if clipped_area.getPoints() is not None: # numpy array has to be explicitly checked against None
|
||||
if clipped_area.getPoints() is not None and len(clipped_area.getPoints()) > 2: # numpy array has to be explicitly checked against None
|
||||
for point in clipped_area.getPoints():
|
||||
converted_points.append(Point(int(point[0] * factor), int(point[1] * factor)))
|
||||
|
||||
|
@ -88,7 +89,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
|
|||
converted_points = []
|
||||
hull_polygon = node.callDecoration("getConvexHull")
|
||||
|
||||
if hull_polygon is not None and hull_polygon.getPoints() is not None: # numpy array has to be explicitly checked against None
|
||||
if hull_polygon is not None and hull_polygon.getPoints() is not None and len(hull_polygon.getPoints()) > 2: # numpy array has to be explicitly checked against None
|
||||
for point in hull_polygon.getPoints():
|
||||
converted_points.append(Point(point[0] * factor, point[1] * factor))
|
||||
item = Item(converted_points)
|
||||
|
@ -99,7 +100,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
|
|||
config = NfpConfig()
|
||||
config.accuracy = 1.0
|
||||
|
||||
num_bins = nest(node_items, build_plate_bounding_box, 10000, config)
|
||||
num_bins = nest(node_items, build_plate_bounding_box, spacing, config)
|
||||
|
||||
# Strip the fixed items (previously placed) and the disallowed areas from the results again.
|
||||
node_items = list(filter(lambda item: not item.isFixed(), node_items))
|
||||
|
@ -109,18 +110,11 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
|
|||
return found_solution_for_all, node_items
|
||||
|
||||
|
||||
def arrange(nodes_to_arrange: List["SceneNode"], build_volume: "BuildVolume", fixed_nodes: Optional[List["SceneNode"]] = None, factor = 10000, add_new_nodes_in_scene: bool = False) -> bool:
|
||||
"""
|
||||
Find placement for a set of scene nodes, and move them by using a single grouped operation.
|
||||
:param nodes_to_arrange: The list of nodes that need to be moved.
|
||||
:param build_volume: The build volume that we want to place the nodes in. It gets size & disallowed areas from this.
|
||||
:param fixed_nodes: List of nods that should not be moved, but should be used when deciding where the others nodes
|
||||
are placed.
|
||||
:param factor: The library that we use is int based. This factor defines how accuracte we want it to be.
|
||||
:param add_new_nodes_in_scene: Whether to create new scene nodes before applying the transformations and rotations
|
||||
|
||||
:return: found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
|
||||
"""
|
||||
def createGroupOperationForArrange(nodes_to_arrange: List["SceneNode"],
|
||||
build_volume: "BuildVolume",
|
||||
fixed_nodes: Optional[List["SceneNode"]] = None,
|
||||
factor = 10000,
|
||||
add_new_nodes_in_scene: bool = False) -> Tuple[GroupedOperation, int]:
|
||||
scene_root = Application.getInstance().getController().getScene().getRoot()
|
||||
found_solution_for_all, node_items = findNodePlacement(nodes_to_arrange, build_volume, fixed_nodes, factor)
|
||||
|
||||
|
@ -142,6 +136,27 @@ def arrange(nodes_to_arrange: List["SceneNode"], build_volume: "BuildVolume", fi
|
|||
grouped_operation.addOperation(
|
||||
TranslateOperation(node, Vector(200, node.getWorldPosition().y, -not_fit_count * 20), set_position = True))
|
||||
not_fit_count += 1
|
||||
grouped_operation.push()
|
||||
|
||||
return found_solution_for_all
|
||||
return grouped_operation, not_fit_count
|
||||
|
||||
|
||||
def arrange(nodes_to_arrange: List["SceneNode"],
|
||||
build_volume: "BuildVolume",
|
||||
fixed_nodes: Optional[List["SceneNode"]] = None,
|
||||
factor = 10000,
|
||||
add_new_nodes_in_scene: bool = False) -> bool:
|
||||
"""
|
||||
Find placement for a set of scene nodes, and move them by using a single grouped operation.
|
||||
:param nodes_to_arrange: The list of nodes that need to be moved.
|
||||
:param build_volume: The build volume that we want to place the nodes in. It gets size & disallowed areas from this.
|
||||
:param fixed_nodes: List of nods that should not be moved, but should be used when deciding where the others nodes
|
||||
are placed.
|
||||
:param factor: The library that we use is int based. This factor defines how accuracte we want it to be.
|
||||
:param add_new_nodes_in_scene: Whether to create new scene nodes before applying the transformations and rotations
|
||||
|
||||
:return: found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
|
||||
"""
|
||||
|
||||
grouped_operation, not_fit_count = createGroupOperationForArrange(nodes_to_arrange, build_volume, fixed_nodes, factor, add_new_nodes_in_scene)
|
||||
grouped_operation.push()
|
||||
return not_fit_count != 0
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import numpy
|
||||
import copy
|
||||
from typing import Optional, Tuple, TYPE_CHECKING
|
||||
from typing import Optional, Tuple, TYPE_CHECKING, Union
|
||||
|
||||
from UM.Math.Polygon import Polygon
|
||||
|
||||
|
@ -14,14 +14,14 @@ if TYPE_CHECKING:
|
|||
class ShapeArray:
|
||||
"""Polygon representation as an array for use with :py:class:`cura.Arranging.Arrange.Arrange`"""
|
||||
|
||||
def __init__(self, arr: numpy.array, offset_x: float, offset_y: float, scale: float = 1) -> None:
|
||||
def __init__(self, arr: numpy.ndarray, offset_x: float, offset_y: float, scale: float = 1) -> None:
|
||||
self.arr = arr
|
||||
self.offset_x = offset_x
|
||||
self.offset_y = offset_y
|
||||
self.scale = scale
|
||||
|
||||
@classmethod
|
||||
def fromPolygon(cls, vertices: numpy.array, scale: float = 1) -> "ShapeArray":
|
||||
def fromPolygon(cls, vertices: numpy.ndarray, scale: float = 1) -> "ShapeArray":
|
||||
"""Instantiate from a bunch of vertices
|
||||
|
||||
:param vertices:
|
||||
|
@ -98,7 +98,7 @@ class ShapeArray:
|
|||
return offset_shape_arr, hull_shape_arr
|
||||
|
||||
@classmethod
|
||||
def arrayFromPolygon(cls, shape: Tuple[int, int], vertices: numpy.array) -> numpy.array:
|
||||
def arrayFromPolygon(cls, shape: Union[Tuple[int, int], numpy.ndarray], vertices: numpy.ndarray) -> numpy.ndarray:
|
||||
"""Create :py:class:`numpy.ndarray` with dimensions defined by shape
|
||||
|
||||
Fills polygon defined by vertices with ones, all other values zero
|
||||
|
@ -110,7 +110,7 @@ class ShapeArray:
|
|||
:return: numpy array with dimensions defined by shape
|
||||
"""
|
||||
|
||||
base_array = numpy.zeros(shape, dtype = numpy.int32) # Initialize your array of zeros
|
||||
base_array = numpy.zeros(shape, dtype = numpy.int32) # type: ignore # Initialize your array of zeros
|
||||
|
||||
fill = numpy.ones(base_array.shape) * True # Initialize boolean array defining shape fill
|
||||
|
||||
|
@ -126,7 +126,7 @@ class ShapeArray:
|
|||
return base_array
|
||||
|
||||
@classmethod
|
||||
def _check(cls, p1: numpy.array, p2: numpy.array, base_array: numpy.array) -> Optional[numpy.array]:
|
||||
def _check(cls, p1: numpy.ndarray, p2: numpy.ndarray, base_array: numpy.ndarray) -> Optional[numpy.ndarray]:
|
||||
"""Return indices that mark one side of the line, used by arrayFromPolygon
|
||||
|
||||
Uses the line defined by p1 and p2 to check array of
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from PyQt5.QtCore import QTimer
|
||||
|
@ -6,6 +6,8 @@ from typing import Any, TYPE_CHECKING
|
|||
|
||||
from UM.Logger import Logger
|
||||
|
||||
import time
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
|
@ -56,8 +58,8 @@ class AutoSave:
|
|||
|
||||
def _onTimeout(self) -> None:
|
||||
self._saving = True # To prevent the save process from triggering another autosave.
|
||||
Logger.log("d", "Autosaving preferences, instances and profiles")
|
||||
|
||||
save_start_time = time.time()
|
||||
self._application.saveSettings()
|
||||
|
||||
Logger.log("d", "Autosaving preferences, instances and profiles took %s seconds", time.time() - save_start_time)
|
||||
self._saving = False
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from copy import deepcopy
|
||||
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.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.Platform import Platform
|
||||
from UM.Resources import Resources
|
||||
from UM.Version import Version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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"]
|
||||
"""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")
|
||||
"""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)
|
||||
|
||||
# obfuscate sensitive secrets
|
||||
secrets = self._obfuscate()
|
||||
|
||||
# Ensure all current settings are saved.
|
||||
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.
|
||||
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)
|
||||
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.
|
||||
self.zip_file = buffer.getvalue()
|
||||
self.meta_data = {
|
||||
|
@ -78,6 +89,8 @@ class Backup:
|
|||
"profile_count": str(profile_count),
|
||||
"plugin_count": str(plugin_count)
|
||||
}
|
||||
# Restore the obfuscated settings
|
||||
self._illuminate(**secrets)
|
||||
|
||||
def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]:
|
||||
"""Make a full archive from the given root path with the given name.
|
||||
|
@ -85,8 +98,7 @@ class Backup:
|
|||
:param root_path: The root directory to archive recursively.
|
||||
:return: The archive as bytes.
|
||||
"""
|
||||
|
||||
ignore_string = re.compile("|".join(self.IGNORED_FILES))
|
||||
ignore_string = re.compile("|".join(self.IGNORED_FILES + self.IGNORED_FOLDERS))
|
||||
try:
|
||||
archive = ZipFile(buffer, "w", ZIP_DEFLATED)
|
||||
for root, folders, files in os.walk(root_path):
|
||||
|
@ -99,15 +111,15 @@ class Backup:
|
|||
return archive
|
||||
except (IOError, OSError, BadZipfile) as error:
|
||||
Logger.log("e", "Could not create archive from user data directory: %s", error)
|
||||
self._showMessage(
|
||||
self.catalog.i18nc("@info:backup_failed",
|
||||
"Could not create archive from user data directory: {}".format(error)))
|
||||
self._showMessage(self.catalog.i18nc("@info:backup_failed",
|
||||
"Could not create archive from user data directory: {}".format(error)),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
return None
|
||||
|
||||
def _showMessage(self, message: str) -> None:
|
||||
def _showMessage(self, message: str, message_type: Message.MessageType = Message.MessageType.NEUTRAL) -> None:
|
||||
"""Show a UI message."""
|
||||
|
||||
Message(message, title=self.catalog.i18nc("@info:title", "Backup"), lifetime=30).show()
|
||||
Message(message, title=self.catalog.i18nc("@info:title", "Backup"), message_type = message_type).show()
|
||||
|
||||
def restore(self) -> bool:
|
||||
"""Restore this back-up.
|
||||
|
@ -118,24 +130,36 @@ class Backup:
|
|||
if not self.zip_file or not self.meta_data or not self.meta_data.get("cura_release", None):
|
||||
# We can restore without the minimum required information.
|
||||
Logger.log("w", "Tried to restore a Cura backup without having proper data or meta data.")
|
||||
self._showMessage(
|
||||
self.catalog.i18nc("@info:backup_failed",
|
||||
"Tried to restore a Cura backup without having proper data or meta data."))
|
||||
self._showMessage(self.catalog.i18nc("@info:backup_failed",
|
||||
"Tried to restore a Cura backup without having proper data or meta data."),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
return False
|
||||
|
||||
current_version = self._application.getVersion()
|
||||
version_to_restore = self.meta_data.get("cura_release", "master")
|
||||
current_version = Version(self._application.getVersion())
|
||||
version_to_restore = Version(self.meta_data.get("cura_release", "master"))
|
||||
|
||||
if current_version < version_to_restore:
|
||||
# Cannot restore version newer than current because settings might have changed.
|
||||
Logger.log("d", "Tried to restore a Cura backup of version {version_to_restore} with cura version {current_version}".format(version_to_restore = version_to_restore, current_version = current_version))
|
||||
self._showMessage(
|
||||
self.catalog.i18nc("@info:backup_failed",
|
||||
"Tried to restore a Cura backup that is higher than the current version."))
|
||||
self._showMessage(self.catalog.i18nc("@info:backup_failed",
|
||||
"Tried to restore a Cura backup that is higher than the current version."),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
return False
|
||||
|
||||
# Get the current secrets and store since the back-up doesn't contain those
|
||||
secrets = self._obfuscate()
|
||||
|
||||
version_data_dir = Resources.getDataStoragePath()
|
||||
archive = ZipFile(io.BytesIO(self.zip_file), "r")
|
||||
try:
|
||||
archive = ZipFile(io.BytesIO(self.zip_file), "r")
|
||||
except LookupError as e:
|
||||
Logger.log("d", f"The following error occurred while trying to restore a Cura backup: {str(e)}")
|
||||
Message(self.catalog.i18nc("@info:backup_failed",
|
||||
"The following error occurred while trying to restore a Cura backup:") + str(e),
|
||||
title = self.catalog.i18nc("@info:title", "Backup"),
|
||||
message_type = Message.MessageType.ERROR).show()
|
||||
|
||||
return False
|
||||
extracted = self._extractArchive(archive, version_data_dir)
|
||||
|
||||
# Under Linux, preferences are stored elsewhere, so we copy the file to there.
|
||||
|
@ -144,7 +168,16 @@ class Backup:
|
|||
preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name))
|
||||
backup_preferences_file = os.path.join(version_data_dir, "{}.cfg".format(preferences_file_name))
|
||||
Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file)
|
||||
shutil.move(backup_preferences_file, preferences_file)
|
||||
try:
|
||||
shutil.move(backup_preferences_file, preferences_file)
|
||||
except EnvironmentError as e:
|
||||
Logger.error(f"Unable to back-up preferences file: {type(e)} - {str(e)}")
|
||||
|
||||
# Read the preferences from the newly restored configuration (or else the cached Preferences will override the restored ones)
|
||||
self._application.readPreferencesFromConfiguration()
|
||||
|
||||
# Restore the obfuscated settings
|
||||
self._illuminate(**secrets)
|
||||
|
||||
return extracted
|
||||
|
||||
|
@ -167,9 +200,38 @@ class Backup:
|
|||
Logger.log("d", "Removing current data in location: %s", target_path)
|
||||
Resources.factoryReset()
|
||||
Logger.log("d", "Extracting backup to location: %s", target_path)
|
||||
try:
|
||||
archive.extractall(target_path)
|
||||
except (PermissionError, EnvironmentError):
|
||||
Logger.logException("e", "Unable to extract the backup due to permission or file system errors.")
|
||||
return False
|
||||
name_list = archive.namelist()
|
||||
for archive_filename in name_list:
|
||||
try:
|
||||
archive.extract(archive_filename, target_path)
|
||||
except (PermissionError, EnvironmentError):
|
||||
Logger.logException("e", f"Unable to extract the file {archive_filename} from the backup due to permission or file system errors.")
|
||||
except UnicodeEncodeError:
|
||||
Logger.error(f"Unable to extract the file {archive_filename} because of an encoding error.")
|
||||
CuraApplication.getInstance().processEvents()
|
||||
return True
|
||||
|
||||
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 UM.Logger import Logger
|
||||
from UM.Version import Version
|
||||
from cura.Backups.Backup import Backup
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
@ -52,6 +53,7 @@ class BackupsManager:
|
|||
|
||||
backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data)
|
||||
restored = backup.restore()
|
||||
|
||||
if restored:
|
||||
# At this point, Cura will need to restart for the changes to take effect.
|
||||
# We don't want to store the data at this point as that would override the just-restored backup.
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import numpy
|
||||
|
@ -95,9 +95,11 @@ class BuildVolume(SceneNode):
|
|||
self._edge_disallowed_size = None
|
||||
|
||||
self._build_volume_message = Message(catalog.i18nc("@info:status",
|
||||
"The build volume height has been reduced due to the value of the"
|
||||
" \"Print Sequence\" setting to prevent the gantry from colliding"
|
||||
" with printed models."), title = catalog.i18nc("@info:title", "Build Volume"))
|
||||
"The build volume height has been reduced due to the value of the"
|
||||
" \"Print Sequence\" setting to prevent the gantry from colliding"
|
||||
" with printed models."),
|
||||
title = catalog.i18nc("@info:title", "Build Volume"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
|
||||
self._global_container_stack = None # type: Optional[GlobalStack]
|
||||
|
||||
|
@ -916,6 +918,8 @@ class BuildVolume(SceneNode):
|
|||
return {}
|
||||
|
||||
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.getMinkowskiHull(Polygon.approximatedCircle(border_size))
|
||||
machine_disallowed_polygons.append(polygon)
|
||||
|
@ -1074,9 +1078,10 @@ class BuildVolume(SceneNode):
|
|||
# setting does *not* have a limit_to_extruder setting (which means that we can't ask the global extruder what
|
||||
# the value is.
|
||||
adhesion_extruder = self._global_container_stack.getProperty("adhesion_extruder_nr", "value")
|
||||
skirt_brim_line_width = self._global_container_stack.extruderList[int(adhesion_extruder)].getProperty("skirt_brim_line_width", "value")
|
||||
adhesion_stack = self._global_container_stack.extruderList[int(adhesion_extruder)]
|
||||
skirt_brim_line_width = adhesion_stack.getProperty("skirt_brim_line_width", "value")
|
||||
|
||||
initial_layer_line_width_factor = self._global_container_stack.getProperty("initial_layer_line_width_factor", "value")
|
||||
initial_layer_line_width_factor = adhesion_stack.getProperty("initial_layer_line_width_factor", "value")
|
||||
# Use brim width if brim is enabled OR the prime tower has a brim.
|
||||
if adhesion_type == "brim":
|
||||
brim_line_count = self._global_container_stack.getProperty("brim_line_count", "value")
|
||||
|
|
|
@ -35,7 +35,7 @@ class CuraActions(QObject):
|
|||
# Starting a web browser from a signal handler connected to a menu will crash on windows.
|
||||
# So instead, defer the call to the next run of the event loop, since that does work.
|
||||
# Note that weirdly enough, only signal handlers that open a web browser fail like that.
|
||||
event = CallFunctionEvent(self._openUrl, [QUrl("https://ultimaker.com/en/resources/manuals/software")], {})
|
||||
event = CallFunctionEvent(self._openUrl, [QUrl("https://ultimaker.com/en/resources/manuals/software?utm_source=cura&utm_medium=software&utm_campaign=dropdown-documentation")], {})
|
||||
cura.CuraApplication.CuraApplication.getInstance().functionEvent(event)
|
||||
|
||||
@pyqtSlot()
|
||||
|
@ -67,11 +67,15 @@ class CuraActions(QObject):
|
|||
current_node = parent_node
|
||||
parent_node = current_node.getParent()
|
||||
|
||||
# This was formerly done with SetTransformOperation but because of
|
||||
# unpredictable matrix deconstruction it was possible that mirrors
|
||||
# could manifest as rotations. Centering is therefore done by
|
||||
# moving the node to negative whatever its position is:
|
||||
center_operation = TranslateOperation(current_node, -current_node._position)
|
||||
# Find out where the bottom of the object is
|
||||
bbox = current_node.getBoundingBox()
|
||||
if bbox:
|
||||
center_y = current_node.getWorldPosition().y - bbox.bottom
|
||||
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.push()
|
||||
|
||||
|
|
|
@ -129,7 +129,7 @@ class CuraApplication(QtApplication):
|
|||
# SettingVersion represents the set of settings available in the machine/extruder definitions.
|
||||
# You need to make sure that this version number needs to be increased if there is any non-backwards-compatible
|
||||
# changes of the settings.
|
||||
SettingVersion = 16
|
||||
SettingVersion = 19
|
||||
|
||||
Created = False
|
||||
|
||||
|
@ -161,7 +161,7 @@ class CuraApplication(QtApplication):
|
|||
|
||||
self.default_theme = "cura-light"
|
||||
|
||||
self.change_log_url = "https://ultimaker.com/ultimaker-cura-latest-features"
|
||||
self.change_log_url = "https://ultimaker.com/ultimaker-cura-latest-features?utm_source=cura&utm_medium=software&utm_campaign=cura-update-features"
|
||||
|
||||
self._boot_loading_time = time.time()
|
||||
|
||||
|
@ -257,6 +257,9 @@ class CuraApplication(QtApplication):
|
|||
from cura.CuraPackageManager import CuraPackageManager
|
||||
self._package_manager_class = CuraPackageManager
|
||||
|
||||
from UM.CentralFileStorage import CentralFileStorage
|
||||
CentralFileStorage.setIsEnterprise(ApplicationMetadata.IsEnterpriseVersion)
|
||||
|
||||
@pyqtProperty(str, constant=True)
|
||||
def ultimakerCloudApiRootUrl(self) -> str:
|
||||
return UltimakerCloudConstants.CuraCloudAPIRoot
|
||||
|
@ -317,7 +320,7 @@ class CuraApplication(QtApplication):
|
|||
super().initialize()
|
||||
|
||||
self._preferences.addPreference("cura/single_instance", False)
|
||||
self._use_single_instance = self._preferences.getValue("cura/single_instance")
|
||||
self._use_single_instance = self._preferences.getValue("cura/single_instance") or self._cli_args.single_instance
|
||||
|
||||
self.__sendCommandToSingleInstance()
|
||||
self._initializeSettingDefinitions()
|
||||
|
@ -458,15 +461,18 @@ class CuraApplication(QtApplication):
|
|||
|
||||
self._version_upgrade_manager.setCurrentVersions(
|
||||
{
|
||||
("quality", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("quality_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityChangesInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("intent", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.IntentInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("machine_stack", GlobalStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.MachineStack, "application/x-cura-globalstack"),
|
||||
("extruder_train", ExtruderStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.ExtruderStack, "application/x-cura-extruderstack"),
|
||||
("preferences", Preferences.Version * 1000000 + self.SettingVersion): (Resources.Preferences, "application/x-uranium-preferences"),
|
||||
("user", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.UserInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("definition_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.DefinitionChangesContainer, "application/x-uranium-instancecontainer"),
|
||||
("variant", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.VariantInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("quality", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("quality_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityChangesInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("intent", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.IntentInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("machine_stack", GlobalStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.MachineStack, "application/x-cura-globalstack"),
|
||||
("extruder_train", ExtruderStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.ExtruderStack, "application/x-cura-extruderstack"),
|
||||
("preferences", Preferences.Version * 1000000 + self.SettingVersion): (Resources.Preferences, "application/x-uranium-preferences"),
|
||||
("user", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.UserInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("definition_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.DefinitionChangesContainer, "application/x-uranium-instancecontainer"),
|
||||
("variant", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.VariantInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("setting_visibility", SettingVisibilityPresetsModel.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.SettingVisibilityPreset, "application/x-uranium-preferences"),
|
||||
("machine", 2): (Resources.DefinitionContainers, "application/x-uranium-definitioncontainer"),
|
||||
("extruder", 2): (Resources.DefinitionContainers, "application/x-uranium-definitioncontainer")
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -704,6 +710,8 @@ class CuraApplication(QtApplication):
|
|||
@pyqtSlot(str)
|
||||
def discardOrKeepProfileChangesClosed(self, option: str) -> None:
|
||||
global_stack = self.getGlobalContainerStack()
|
||||
if global_stack is None:
|
||||
return
|
||||
if option == "discard":
|
||||
for extruder in global_stack.extruderList:
|
||||
extruder.userChanges.clear()
|
||||
|
@ -742,7 +750,9 @@ class CuraApplication(QtApplication):
|
|||
@pyqtSlot(str, result = QUrl)
|
||||
def getDefaultPath(self, key):
|
||||
default_path = self.getPreferences().getValue("local_file/%s" % key)
|
||||
return QUrl.fromLocalFile(default_path)
|
||||
if os.path.exists(default_path):
|
||||
return QUrl.fromLocalFile(default_path)
|
||||
return QUrl()
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def setDefaultPath(self, key, default_path):
|
||||
|
@ -1304,9 +1314,9 @@ class CuraApplication(QtApplication):
|
|||
if not isinstance(node, SceneNode):
|
||||
continue
|
||||
if not node.getMeshData() and not node.callDecoration("isGroup"):
|
||||
continue # Node that doesnt have a mesh and is not a group.
|
||||
continue # Node that doesn't have a mesh and is not a group.
|
||||
if node.getParent() and node.getParent().callDecoration("isGroup") or node.getParent().callDecoration("isSliceable"):
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
|
||||
if not node.isSelectable():
|
||||
continue # i.e. node with layer data
|
||||
if not node.callDecoration("isSliceable") and not node.callDecoration("isGroup"):
|
||||
|
@ -1324,9 +1334,9 @@ class CuraApplication(QtApplication):
|
|||
if not isinstance(node, SceneNode):
|
||||
continue
|
||||
if not node.getMeshData() and not node.callDecoration("isGroup"):
|
||||
continue # Node that doesnt have a mesh and is not a group.
|
||||
continue # Node that doesn't have a mesh and is not a group.
|
||||
if node.getParent() and node.getParent().callDecoration("isGroup"):
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
|
||||
if not node.isSelectable():
|
||||
continue # i.e. node with layer data
|
||||
nodes.append(node)
|
||||
|
@ -1353,9 +1363,9 @@ class CuraApplication(QtApplication):
|
|||
if not isinstance(node, SceneNode):
|
||||
continue
|
||||
if not node.getMeshData() and not node.callDecoration("isGroup"):
|
||||
continue # Node that doesnt have a mesh and is not a group.
|
||||
continue # Node that doesn't have a mesh and is not a group.
|
||||
if node.getParent() and node.getParent().callDecoration("isGroup"):
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
|
||||
if not node.callDecoration("isSliceable") and not node.callDecoration("isGroup"):
|
||||
continue # i.e. node with layer data
|
||||
nodes.append(node)
|
||||
|
@ -1382,7 +1392,7 @@ class CuraApplication(QtApplication):
|
|||
continue
|
||||
|
||||
if not node.getMeshData() and not node.callDecoration("isGroup"):
|
||||
continue # Node that doesnt have a mesh and is not a group.
|
||||
continue # Node that doesn't have a mesh and is not a group.
|
||||
|
||||
parent_node = node.getParent()
|
||||
if parent_node and parent_node.callDecoration("isGroup"):
|
||||
|
@ -1410,11 +1420,11 @@ class CuraApplication(QtApplication):
|
|||
continue
|
||||
|
||||
if not node.getMeshData() and not node.callDecoration("isGroup"):
|
||||
continue # Node that doesnt have a mesh and is not a group.
|
||||
continue # Node that doesn't have a mesh and is not a group.
|
||||
|
||||
parent_node = node.getParent()
|
||||
if parent_node and parent_node.callDecoration("isGroup"):
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
|
||||
|
||||
if not node.isSelectable():
|
||||
continue # i.e. node with layer data
|
||||
|
@ -1525,12 +1535,8 @@ class CuraApplication(QtApplication):
|
|||
|
||||
# Compute the center of the objects
|
||||
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()):
|
||||
transformation = node.getLocalTransformation()
|
||||
transformation.setTranslation(zero_translation)
|
||||
transformed_mesh = mesh.getTransformed(transformation)
|
||||
transformed_mesh = mesh.getTransformed(Matrix()) # Forget about the transformations that the original object had.
|
||||
center = transformed_mesh.getCenterPosition()
|
||||
if center is not None:
|
||||
object_centers.append(center)
|
||||
|
@ -1545,7 +1551,7 @@ class CuraApplication(QtApplication):
|
|||
|
||||
# Move each node to the same position.
|
||||
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
|
||||
# and also apply the offset to center it inside the group.
|
||||
node.setPosition(-mesh.getZeroPosition() - offset)
|
||||
|
@ -1796,8 +1802,10 @@ class CuraApplication(QtApplication):
|
|||
if extension in self._non_sliceable_extensions:
|
||||
message = Message(
|
||||
self._i18n_catalog.i18nc("@info:status",
|
||||
"Only one G-code file can be loaded at a time. Skipped importing {0}",
|
||||
filename), title = self._i18n_catalog.i18nc("@info:title", "Warning"))
|
||||
"Only one G-code file can be loaded at a time. Skipped importing {0}",
|
||||
filename),
|
||||
title = self._i18n_catalog.i18nc("@info:title", "Warning"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
message.show()
|
||||
return
|
||||
# If file being loaded is non-slicable file, then prevent loading of any other files
|
||||
|
@ -1806,8 +1814,10 @@ class CuraApplication(QtApplication):
|
|||
if extension in self._non_sliceable_extensions:
|
||||
message = Message(
|
||||
self._i18n_catalog.i18nc("@info:status",
|
||||
"Can't open any other file if G-code is loading. Skipped importing {0}",
|
||||
filename), title = self._i18n_catalog.i18nc("@info:title", "Error"))
|
||||
"Can't open any other file if G-code is loading. Skipped importing {0}",
|
||||
filename),
|
||||
title = self._i18n_catalog.i18nc("@info:title", "Error"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
message.show()
|
||||
return
|
||||
|
||||
|
@ -1866,6 +1876,7 @@ class CuraApplication(QtApplication):
|
|||
else:
|
||||
node = CuraSceneNode()
|
||||
node.setMeshData(original_node.getMeshData())
|
||||
node.source_mime_type = original_node.source_mime_type
|
||||
|
||||
# Setting meshdata does not apply scaling.
|
||||
if original_node.getScale() != Vector(1.0, 1.0, 1.0):
|
||||
|
@ -2030,11 +2041,11 @@ class CuraApplication(QtApplication):
|
|||
if not node.isEnabled():
|
||||
continue
|
||||
if (not node.getMeshData() and not node.callDecoration("getLayerData")) and not node.callDecoration("isGroup"):
|
||||
continue # Node that doesnt have a mesh and is not a group.
|
||||
continue # Node that doesn't have a mesh and is not a group.
|
||||
if only_selectable and not node.isSelectable():
|
||||
continue # Only remove nodes that are selectable.
|
||||
if not node.callDecoration("isSliceable") and not node.callDecoration("getLayerData") and not node.callDecoration("isGroup"):
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
|
||||
nodes.append(node)
|
||||
if nodes:
|
||||
from UM.Operations.GroupedOperation import GroupedOperation
|
||||
|
|
|
@ -12,7 +12,7 @@ from cura.CuraApplication import CuraApplication
|
|||
# Since Cura has a few pre-defined "space claims" for the locations of certain components, we've provided some structure
|
||||
# to indicate this.
|
||||
# MainComponent works in the same way the MainComponent of a stage.
|
||||
# the stageMenuComponent returns an item that should be used somehwere in the stage menu. It's up to the active stage
|
||||
# the stageMenuComponent returns an item that should be used somewhere in the stage menu. It's up to the active stage
|
||||
# to actually do something with this.
|
||||
class CuraView(View):
|
||||
def __init__(self, parent = None, use_empty_menu_placeholder: bool = False) -> None:
|
||||
|
|
|
@ -59,13 +59,13 @@ class LayerPolygon:
|
|||
self._vertex_count = self._mesh_line_count + numpy.sum(self._types[1:] == self._types[:-1])
|
||||
|
||||
# Buffering the colors shouldn't be necessary as it is not
|
||||
# re-used and can save alot of memory usage.
|
||||
# re-used and can save a lot of memory usage.
|
||||
self._color_map = LayerPolygon.getColorMap()
|
||||
self._colors = self._color_map[self._types] # type: numpy.ndarray
|
||||
|
||||
# When type is used as index returns true if type == LayerPolygon.InfillType or type == LayerPolygon.SkinType or type == LayerPolygon.SupportInfillType
|
||||
# Should be generated in better way, not hardcoded.
|
||||
self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = numpy.bool)
|
||||
self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = bool)
|
||||
|
||||
self._build_cache_line_mesh_mask = None # type: Optional[numpy.ndarray]
|
||||
self._build_cache_needed_points = None # type: Optional[numpy.ndarray]
|
||||
|
@ -73,18 +73,17 @@ class LayerPolygon:
|
|||
def buildCache(self) -> None:
|
||||
# For the line mesh we do not draw Infill or Jumps. Therefore those lines are filtered out.
|
||||
self._build_cache_line_mesh_mask = numpy.ones(self._jump_mask.shape, dtype = bool)
|
||||
mesh_line_count = numpy.sum(self._build_cache_line_mesh_mask)
|
||||
self._index_begin = 0
|
||||
self._index_end = mesh_line_count
|
||||
self._index_end = cast(int, numpy.sum(self._build_cache_line_mesh_mask))
|
||||
|
||||
self._build_cache_needed_points = numpy.ones((len(self._types), 2), dtype = numpy.bool)
|
||||
self._build_cache_needed_points = numpy.ones((len(self._types), 2), dtype = bool)
|
||||
# Only if the type of line segment changes do we need to add an extra vertex to change colors
|
||||
self._build_cache_needed_points[1:, 0][:, numpy.newaxis] = self._types[1:] != self._types[:-1]
|
||||
# Mark points as unneeded if they are of types we don't want in the line mesh according to the calculated mask
|
||||
numpy.logical_and(self._build_cache_needed_points, self._build_cache_line_mesh_mask, self._build_cache_needed_points )
|
||||
|
||||
self._vertex_begin = 0
|
||||
self._vertex_end = numpy.sum( self._build_cache_needed_points )
|
||||
self._vertex_end = cast(int, numpy.sum(self._build_cache_needed_points))
|
||||
|
||||
def build(self, vertex_offset: int, index_offset: int, vertices: numpy.ndarray, colors: numpy.ndarray, line_dimensions: numpy.ndarray, feedrates: numpy.ndarray, extruders: numpy.ndarray, line_types: numpy.ndarray, indices: numpy.ndarray) -> None:
|
||||
"""Set all the arrays provided by the function caller, representing the LayerPolygon
|
||||
|
@ -147,7 +146,7 @@ class LayerPolygon:
|
|||
# When the line type changes the index needs to be increased by 2.
|
||||
indices[self._index_begin:self._index_end, :] += numpy.cumsum(needed_points_list[line_mesh_mask.ravel(), 0], dtype = numpy.int32).reshape((-1, 1))
|
||||
# Each line segment goes from it's starting point p to p+1, offset by the vertex index.
|
||||
# The -1 is to compensate for the neccecarily True value of needed_points_list[0,0] which causes an unwanted +1 in cumsum above.
|
||||
# The -1 is to compensate for the necessarily True value of needed_points_list[0,0] which causes an unwanted +1 in cumsum above.
|
||||
indices[self._index_begin:self._index_end, :] += numpy.array([self._vertex_begin - 1, self._vertex_begin])
|
||||
|
||||
self._build_cache_line_mesh_mask = None
|
||||
|
|
|
@ -97,8 +97,7 @@ class MachineErrorChecker(QObject):
|
|||
|
||||
def startErrorCheckPropertyChanged(self, key: str, property_name: str) -> None:
|
||||
"""Start the error check for property changed
|
||||
|
||||
this is seperate from the startErrorCheck because it ignores a number property types
|
||||
this is separate from the startErrorCheck because it ignores a number property types
|
||||
|
||||
:param key:
|
||||
:param property_name:
|
||||
|
|
|
@ -53,9 +53,14 @@ class ExtrudersModel(ListModel):
|
|||
EnabledRole = Qt.UserRole + 11
|
||||
"""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"]
|
||||
"""List of colours to display if there is no material or the material has no known colour. """
|
||||
|
||||
MaterialNameRole = Qt.UserRole + 13
|
||||
|
||||
def __init__(self, parent = None):
|
||||
"""Initialises the extruders model, defining the roles and listening for changes in the data.
|
||||
|
||||
|
@ -75,6 +80,8 @@ class ExtrudersModel(ListModel):
|
|||
self.addRoleName(self.StackRole, "stack")
|
||||
self.addRoleName(self.MaterialBrandRole, "material_brand")
|
||||
self.addRoleName(self.ColorNameRole, "color_name")
|
||||
self.addRoleName(self.MaterialTypeRole, "material_type")
|
||||
self.addRoleName(self.MaterialNameRole, "material_name")
|
||||
self._update_extruder_timer = QTimer()
|
||||
self._update_extruder_timer.setInterval(100)
|
||||
self._update_extruder_timer.setSingleShot(True)
|
||||
|
@ -193,9 +200,10 @@ class ExtrudersModel(ListModel):
|
|||
"variant": extruder.variant.getName() if extruder.variant else "", # e.g. print core
|
||||
"stack": extruder,
|
||||
"material_brand": material_brand,
|
||||
"color_name": color_name
|
||||
"color_name": color_name,
|
||||
"material_type": extruder.material.getMetaDataEntry("material") if extruder.material else "",
|
||||
"material_name": extruder.material.getMetaDataEntry("name") if extruder.material else "",
|
||||
}
|
||||
|
||||
items.append(item)
|
||||
extruders_changed = True
|
||||
|
||||
|
@ -210,7 +218,7 @@ class ExtrudersModel(ListModel):
|
|||
"id": "",
|
||||
"name": catalog.i18nc("@menuitem", "Not overridden"),
|
||||
"enabled": True,
|
||||
"color": "#ffffff",
|
||||
"color": "transparent",
|
||||
"index": -1,
|
||||
"definition": "",
|
||||
"material": "",
|
||||
|
@ -218,6 +226,8 @@ class ExtrudersModel(ListModel):
|
|||
"stack": None,
|
||||
"material_brand": "",
|
||||
"color_name": "",
|
||||
"material_type": "",
|
||||
"material_label": ""
|
||||
}
|
||||
items.append(item)
|
||||
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.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
@ -34,4 +34,4 @@ def fetchLayerHeight(quality_group: "QualityGroup") -> float:
|
|||
if isinstance(layer_height, SettingFunction):
|
||||
layer_height = layer_height(global_stack)
|
||||
|
||||
return float(layer_height)
|
||||
return round(float(layer_height), 3)
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
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
|
||||
import uuid # To generate new GUIDs for new materials.
|
||||
import zipfile # To export all materials in a .zip archive.
|
||||
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.Signal import postponeSignals, CompressTechnique
|
||||
|
||||
import cura.CuraApplication # Imported like this to prevent circular imports.
|
||||
|
@ -19,18 +23,71 @@ if TYPE_CHECKING:
|
|||
|
||||
catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
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)
|
||||
"""Triggered when a favorite is added or removed.
|
||||
|
||||
:param The base file of the material is provided as parameter when this emits
|
||||
"""
|
||||
|
||||
def __init__(self, parent: Optional[QObject] = None) -> None:
|
||||
super().__init__(parent = parent)
|
||||
self._checkIfNewMaterialsWereInstalled()
|
||||
|
||||
def _checkIfNewMaterialsWereInstalled(self) -> None:
|
||||
"""
|
||||
Checks whether new material packages were installed in the latest startup. If there were, then it shows
|
||||
a message prompting the user to sync the materials with their printers.
|
||||
"""
|
||||
application = cura.CuraApplication.CuraApplication.getInstance()
|
||||
for package_id, package_data in application.getPackageManager().getPackagesInstalledOnStartup().items():
|
||||
if package_data["package_info"]["package_type"] == "material":
|
||||
# At least one new material was installed
|
||||
self._showSyncNewMaterialsMessage()
|
||||
break
|
||||
|
||||
def _showSyncNewMaterialsMessage(self) -> None:
|
||||
sync_materials_message = Message(
|
||||
text = catalog.i18nc("@action:button",
|
||||
"Please sync the material profiles with your printers before starting to print."),
|
||||
title = catalog.i18nc("@action:button", "New materials installed"),
|
||||
message_type = Message.MessageType.WARNING,
|
||||
lifetime = 0
|
||||
)
|
||||
|
||||
sync_materials_message.addAction(
|
||||
"sync",
|
||||
name = catalog.i18nc("@action:button", "Sync materials with printers"),
|
||||
icon = "",
|
||||
description = "Sync your newly installed materials with your printers.",
|
||||
button_align = Message.ActionButtonAlignment.ALIGN_RIGHT
|
||||
)
|
||||
|
||||
sync_materials_message.addAction(
|
||||
"learn_more",
|
||||
name = catalog.i18nc("@action:button", "Learn more"),
|
||||
icon = "",
|
||||
description = "Learn more about syncing your newly installed materials with your printers.",
|
||||
button_align = Message.ActionButtonAlignment.ALIGN_LEFT,
|
||||
button_style = Message.ActionButtonStyle.LINK
|
||||
)
|
||||
sync_materials_message.actionTriggered.connect(self._onSyncMaterialsMessageActionTriggered)
|
||||
|
||||
# Show the message only if there are printers that support material export
|
||||
container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
|
||||
global_stacks = container_registry.findContainerStacks(type = "machine")
|
||||
if any([stack.supportsMaterialExport for stack in global_stacks]):
|
||||
sync_materials_message.show()
|
||||
|
||||
def _onSyncMaterialsMessageActionTriggered(self, sync_message: Message, sync_message_action: str):
|
||||
if sync_message_action == "sync":
|
||||
QDesktopServices.openUrl(QUrl("https://example.com/openSyncAllWindow"))
|
||||
# self.openSyncAllWindow()
|
||||
sync_message.hide()
|
||||
elif sync_message_action == "learn_more":
|
||||
QDesktopServices.openUrl(QUrl("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-printer-message"))
|
||||
|
||||
@pyqtSlot("QVariant", result = bool)
|
||||
def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool:
|
||||
"""Can a certain material be deleted, or is it still in use in one of the container stacks anywhere?
|
||||
|
@ -79,6 +136,7 @@ class MaterialManagementModel(QObject):
|
|||
|
||||
:param material_node: The material to remove.
|
||||
"""
|
||||
Logger.info(f"Removing material {material_node.container_id}")
|
||||
|
||||
container_registry = CuraContainerRegistry.getInstance()
|
||||
materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file)
|
||||
|
@ -194,6 +252,7 @@ class MaterialManagementModel(QObject):
|
|||
|
||||
: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)
|
||||
|
||||
@pyqtSlot(result = str)
|
||||
|
@ -262,3 +321,53 @@ class MaterialManagementModel(QObject):
|
|||
self.favoritesChanged.emit(material_base_file)
|
||||
except ValueError: # Material was not in the favorites list.
|
||||
Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file))
|
||||
|
||||
@pyqtSlot(result = QUrl)
|
||||
def getPreferredExportAllPath(self) -> QUrl:
|
||||
"""
|
||||
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()
|
||||
|
||||
try:
|
||||
archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED)
|
||||
except OSError as e:
|
||||
Logger.log("e", f"Can't write to destination {file_path.toLocalFile()}: {type(e)} - {str(e)}")
|
||||
error_message = Message(
|
||||
text = catalog.i18nc("@message:text", "Could not save material archive to {}:").format(file_path.toLocalFile()) + " " + str(e),
|
||||
title = catalog.i18nc("@message:title", "Failed to save material archive"),
|
||||
message_type = Message.MessageType.ERROR
|
||||
)
|
||||
error_message.show()
|
||||
return
|
||||
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
|
||||
try:
|
||||
archive.writestr(filename, material.serialize())
|
||||
except OSError as e:
|
||||
Logger.log("e", f"An error has occurred while writing the material \'{metadata['id']}\' in the file \'{filename}\': {e}.")
|
||||
|
|
|
@ -41,10 +41,6 @@ class QualityProfilesDropDownMenuModel(ListModel):
|
|||
machine_manager.activeQualityGroupChanged.connect(self._onChange)
|
||||
machine_manager.activeMaterialChanged.connect(self._onChange)
|
||||
machine_manager.activeVariantChanged.connect(self._onChange)
|
||||
machine_manager.extruderChanged.connect(self._onChange)
|
||||
|
||||
extruder_manager = application.getExtruderManager()
|
||||
extruder_manager.extrudersChanged.connect(self._onChange)
|
||||
|
||||
self._layer_height_unit = "" # This is cached
|
||||
|
||||
|
|
|
@ -99,7 +99,7 @@ class QualitySettingsModel(ListModel):
|
|||
if self._selected_position == self.GLOBAL_STACK_POSITION:
|
||||
quality_node = quality_group.node_for_global
|
||||
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()
|
||||
quality_containers = []
|
||||
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]
|
||||
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()}
|
||||
quality_changes_metadata = None
|
||||
if self._selected_position == self.GLOBAL_STACK_POSITION and global_container:
|
||||
quality_changes_metadata = global_container.getMetaData()
|
||||
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.
|
||||
container = container_registry.findContainers(id = quality_changes_metadata["id"])
|
||||
if container:
|
||||
|
|
|
@ -19,6 +19,8 @@ class SettingVisibilityPresetsModel(QObject):
|
|||
onItemsChanged = pyqtSignal()
|
||||
activePresetChanged = pyqtSignal()
|
||||
|
||||
Version = 2
|
||||
|
||||
def __init__(self, preferences: Preferences, parent = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
@ -31,7 +33,7 @@ class SettingVisibilityPresetsModel(QObject):
|
|||
if basic_item is not None:
|
||||
basic_visibile_settings = ";".join(basic_item.settings)
|
||||
else:
|
||||
Logger.log("w", "Unable to find the basic visiblity preset.")
|
||||
Logger.log("w", "Unable to find the basic visibility preset.")
|
||||
basic_visibile_settings = ""
|
||||
|
||||
self._preferences = preferences
|
||||
|
|
|
@ -6,11 +6,15 @@ from typing import List
|
|||
|
||||
from UM.Application import Application
|
||||
from UM.Job import Job
|
||||
from UM.Math.Vector import Vector
|
||||
from UM.Message import Message
|
||||
from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
|
||||
from UM.Operations.GroupedOperation import GroupedOperation
|
||||
from UM.Operations.TranslateOperation import TranslateOperation
|
||||
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from UM.i18n import i18nCatalog
|
||||
from cura.Arranging.Nest2DArrange import arrange
|
||||
from cura.Arranging.Nest2DArrange import arrange, createGroupOperationForArrange
|
||||
|
||||
i18n_catalog = i18nCatalog("cura")
|
||||
|
||||
|
@ -43,11 +47,11 @@ class MultiplyObjectsJob(Job):
|
|||
# Only count sliceable objects
|
||||
if node_.callDecoration("isSliceable"):
|
||||
fixed_nodes.append(node_)
|
||||
|
||||
nodes_to_add_without_arrange = []
|
||||
for node in self._objects:
|
||||
# If object is part of a group, multiply group
|
||||
current_node = node
|
||||
while current_node.getParent() and (current_node.getParent().callDecoration("isGroup") or current_node.getParent().callDecoration("isSliceable")):
|
||||
while current_node.getParent() and current_node.getParent().callDecoration("isGroup"):
|
||||
current_node = current_node.getParent()
|
||||
|
||||
if current_node in processed_nodes:
|
||||
|
@ -56,23 +60,43 @@ class MultiplyObjectsJob(Job):
|
|||
|
||||
for _ in range(self._count):
|
||||
new_node = copy.deepcopy(node)
|
||||
|
||||
# Same build plate
|
||||
build_plate_number = current_node.callDecoration("getBuildPlateNumber")
|
||||
new_node.callDecoration("setBuildPlateNumber", build_plate_number)
|
||||
for child in new_node.getChildren():
|
||||
child.callDecoration("setBuildPlateNumber", build_plate_number)
|
||||
|
||||
nodes.append(new_node)
|
||||
if not current_node.getParent().callDecoration("isSliceable"):
|
||||
nodes.append(new_node)
|
||||
else:
|
||||
# The node we're trying to place has another node that is sliceable as a parent.
|
||||
# As such, we shouldn't arrange it (but it should be added to the scene!)
|
||||
nodes_to_add_without_arrange.append(new_node)
|
||||
new_node.setParent(current_node.getParent())
|
||||
|
||||
found_solution_for_all = True
|
||||
group_operation = GroupedOperation()
|
||||
if nodes:
|
||||
found_solution_for_all = arrange(nodes, Application.getInstance().getBuildVolume(), fixed_nodes,
|
||||
factor = 10000, add_new_nodes_in_scene = True)
|
||||
group_operation, not_fit_count = createGroupOperationForArrange(nodes,
|
||||
Application.getInstance().getBuildVolume(),
|
||||
fixed_nodes,
|
||||
factor = 10000,
|
||||
add_new_nodes_in_scene = True)
|
||||
found_solution_for_all = not_fit_count == 0
|
||||
|
||||
if nodes_to_add_without_arrange:
|
||||
for nested_node in nodes_to_add_without_arrange:
|
||||
group_operation.addOperation(AddSceneNodeOperation(nested_node, nested_node.getParent()))
|
||||
# Move the node a tiny bit so it doesn't overlap with the existing one.
|
||||
# This doesn't fix it if someone creates more than one duplicate, but it at least shows that something
|
||||
# happened (and after moving it, it's clear that there are more underneath)
|
||||
group_operation.addOperation(TranslateOperation(nested_node, Vector(2.5, 2.5, 2.5)))
|
||||
|
||||
group_operation.push()
|
||||
status_message.hide()
|
||||
|
||||
if not found_solution_for_all:
|
||||
no_full_solution_message = Message(
|
||||
i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"),
|
||||
title = i18n_catalog.i18nc("@info:title", "Placing Object"))
|
||||
title = i18n_catalog.i18nc("@info:title", "Placing Object"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
no_full_solution_message.show()
|
||||
|
|
|
@ -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.
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import random
|
||||
import secrets
|
||||
from hashlib import sha512
|
||||
from base64 import b64encode
|
||||
from typing import Optional, Any, Dict, Tuple
|
||||
|
||||
from typing import Optional
|
||||
import requests
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
|
@ -48,8 +48,8 @@ class AuthorizationHelpers:
|
|||
}
|
||||
try:
|
||||
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore
|
||||
except requests.exceptions.ConnectionError:
|
||||
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server")
|
||||
except requests.exceptions.ConnectionError as connection_error:
|
||||
return AuthenticationResponse(success = False, err_message = f"Unable to connect to remote server: {connection_error}")
|
||||
|
||||
def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse":
|
||||
"""Request the access token from the authorization server using a refresh token.
|
||||
|
@ -115,7 +115,7 @@ class AuthorizationHelpers:
|
|||
token_request = requests.get(check_token_url, headers = {
|
||||
"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.
|
||||
Logger.logException("w", "Something failed while attempting to parse the JWT token")
|
||||
return None
|
||||
|
@ -139,11 +139,11 @@ class AuthorizationHelpers:
|
|||
def generateVerificationCode(code_length: int = 32) -> str:
|
||||
"""Generate a verification code of arbitrary length.
|
||||
|
||||
:param code_length:: How long should the code be? This should never be lower than 16, but it's probably
|
||||
:param code_length:: How long should the code be in bytes? This should never be lower than 16, but it's probably
|
||||
better to leave it at 32
|
||||
"""
|
||||
|
||||
return "".join(random.choice("0123456789ABCDEF") for i in range(code_length))
|
||||
return secrets.token_hex(code_length)
|
||||
|
||||
@staticmethod
|
||||
def generateVerificationCodeChallenge(verification_code: str) -> str:
|
||||
|
|
|
@ -24,7 +24,7 @@ if TYPE_CHECKING:
|
|||
from cura.OAuth2.Models import UserProfile, OAuth2Settings
|
||||
from UM.Preferences import Preferences
|
||||
|
||||
MYCLOUD_LOGOFF_URL = "https://mycloud.ultimaker.com/logoff"
|
||||
MYCLOUD_LOGOFF_URL = "https://account.ultimaker.com/logoff?utm_source=cura&utm_medium=software&utm_campaign=change-account-before-adding-printers"
|
||||
|
||||
class AuthorizationService:
|
||||
"""The authorization service is responsible for handling the login flow, storing user credentials and providing
|
||||
|
@ -99,7 +99,14 @@ class AuthorizationService:
|
|||
# If no auth data exists, we should always log in again.
|
||||
Logger.log("d", "There was no auth data or access token")
|
||||
return None
|
||||
user_data = self._auth_helpers.parseJWT(self._auth_data.access_token)
|
||||
|
||||
try:
|
||||
user_data = self._auth_helpers.parseJWT(self._auth_data.access_token)
|
||||
except AttributeError:
|
||||
# THis might seem a bit double, but we get crash reports about this (CURA-2N2 in sentry)
|
||||
Logger.log("d", "There was no auth data or access token")
|
||||
return None
|
||||
|
||||
if user_data:
|
||||
# If the profile was found, we return it immediately.
|
||||
return user_data
|
||||
|
@ -113,12 +120,14 @@ class AuthorizationService:
|
|||
# The token could not be refreshed using the refresh token. We should login again.
|
||||
return None
|
||||
# Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been deleted
|
||||
# from the server already.
|
||||
self._storeAuthData(self._auth_data)
|
||||
# from the server already. Do not store the auth_data if we could not get new auth_data (eg due to a
|
||||
# network error), since this would cause an infinite loop trying to get new auth-data
|
||||
if self._auth_data.success:
|
||||
self._storeAuthData(self._auth_data)
|
||||
return self._auth_helpers.parseJWT(self._auth_data.access_token)
|
||||
|
||||
def getAccessToken(self) -> Optional[str]:
|
||||
"""Get the access token as provided by the repsonse data."""
|
||||
"""Get the access token as provided by the response data."""
|
||||
|
||||
if self._auth_data is None:
|
||||
Logger.log("d", "No auth data to retrieve the access_token from")
|
||||
|
@ -184,8 +193,10 @@ class AuthorizationService:
|
|||
self._server.start(verification_code, state)
|
||||
except OSError:
|
||||
Logger.logException("w", "Unable to create authorization request server")
|
||||
Message(i18n_catalog.i18nc("@info", "Unable to start a new sign in process. Check if another sign in attempt is still active."),
|
||||
title=i18n_catalog.i18nc("@info:title", "Warning")).show()
|
||||
Message(i18n_catalog.i18nc("@info",
|
||||
"Unable to start a new sign in process. Check if another sign in attempt is still active."),
|
||||
title=i18n_catalog.i18nc("@info:title", "Warning"),
|
||||
message_type = Message.MessageType.WARNING).show()
|
||||
return
|
||||
|
||||
auth_url = self._generate_auth_url(query_parameters_dict, force_browser_logout)
|
||||
|
@ -205,25 +216,27 @@ class AuthorizationService:
|
|||
link to force the a browser logout from mycloud.ultimaker.com
|
||||
:return: The authentication URL, properly formatted and encoded
|
||||
"""
|
||||
auth_url = "{}?{}".format(self._auth_url, urlencode(query_parameters_dict))
|
||||
auth_url = f"{self._auth_url}?{urlencode(query_parameters_dict)}"
|
||||
if force_browser_logout:
|
||||
# The url after '?next=' should be urlencoded
|
||||
auth_url = "{}?next={}".format(MYCLOUD_LOGOFF_URL, quote_plus(auth_url))
|
||||
connecting_char = "&" if "?" in MYCLOUD_LOGOFF_URL else "?"
|
||||
# The url after 'next=' should be urlencoded
|
||||
auth_url = f"{MYCLOUD_LOGOFF_URL}{connecting_char}next={quote_plus(auth_url)}"
|
||||
return auth_url
|
||||
|
||||
def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:
|
||||
"""Callback method for the authentication flow."""
|
||||
|
||||
if auth_response.success:
|
||||
Logger.log("d", "Got callback from Authorization state. The user should now be logged in!")
|
||||
self._storeAuthData(auth_response)
|
||||
self.onAuthStateChanged.emit(logged_in = True)
|
||||
else:
|
||||
Logger.log("d", "Got callback from Authorization state. Something went wrong: [%s]", auth_response.err_message)
|
||||
self.onAuthenticationError.emit(logged_in = False, error_message = auth_response.err_message)
|
||||
self._server.stop() # Stop the web server at all times.
|
||||
|
||||
def loadAuthDataFromPreferences(self) -> None:
|
||||
"""Load authentication data from preferences."""
|
||||
|
||||
Logger.log("d", "Attempting to load the auth data from preferences.")
|
||||
if self._preferences is None:
|
||||
Logger.log("e", "Unable to load authentication data, since no preference has been set!")
|
||||
return
|
||||
|
@ -235,11 +248,16 @@ class AuthorizationService:
|
|||
user_profile = self.getUserProfile()
|
||||
if user_profile is not None:
|
||||
self.onAuthStateChanged.emit(logged_in = True)
|
||||
Logger.log("d", "Auth data was successfully loaded")
|
||||
else:
|
||||
if self._unable_to_get_data_message is not None:
|
||||
self._unable_to_get_data_message.hide()
|
||||
|
||||
self._unable_to_get_data_message = Message(i18n_catalog.i18nc("@info", "Unable to reach the Ultimaker account server."), title = i18n_catalog.i18nc("@info:title", "Warning"))
|
||||
self._unable_to_get_data_message = Message(i18n_catalog.i18nc("@info",
|
||||
"Unable to reach the Ultimaker account server."),
|
||||
title = i18n_catalog.i18nc("@info:title", "Warning"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
Logger.log("w", "Unable to load auth data from preferences")
|
||||
self._unable_to_get_data_message.show()
|
||||
except (ValueError, TypeError):
|
||||
Logger.logException("w", "Could not load auth data from preferences")
|
||||
|
@ -255,10 +273,10 @@ class AuthorizationService:
|
|||
self._auth_data = auth_data
|
||||
if auth_data:
|
||||
self._user_profile = self.getUserProfile()
|
||||
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data)))
|
||||
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(auth_data.dump()))
|
||||
else:
|
||||
Logger.log("d", "Clearing the user profile")
|
||||
self._user_profile = None
|
||||
self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)
|
||||
|
||||
self.accessTokenChanged.emit()
|
||||
|
||||
|
|
92
cura/OAuth2/KeyringAttribute.py
Normal file
92
cura/OAuth2/KeyringAttribute.py
Normal file
|
@ -0,0 +1,92 @@
|
|||
# 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():
|
||||
if hasattr(sys, "frozen"):
|
||||
import win32timezone
|
||||
from keyring.backends.Windows import WinVaultKeyring
|
||||
keyring.set_keyring(WinVaultKeyring())
|
||||
if Platform.isOSX():
|
||||
from keyring.backends.macOS import Keyring
|
||||
keyring.set_keyring(Keyring())
|
||||
if Platform.isLinux():
|
||||
# We do not support the keyring on Linux, so make sure no Keyring backend is loaded, even if there is a system one.
|
||||
from keyring.backends.fail import Keyring as NoKeyringBackend
|
||||
keyring.set_keyring(NoKeyringBackend())
|
||||
|
||||
# Even if errors happen, we don't want this stored locally:
|
||||
DONT_EVER_STORE_LOCALLY: List[str] = ["refresh_token"]
|
||||
|
||||
|
||||
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)
|
||||
except UnicodeDecodeError:
|
||||
self._store_secure = False
|
||||
Logger.log("w", "The password retrieved from the keyring cannot be used because it contains characters that cannot be decoded.")
|
||||
return getattr(instance, self._name)
|
||||
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 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.
|
||||
Logger.log("d", "Auth web server was already running. Updating the verification code")
|
||||
self._web_server.setVerificationCode(verification_code)
|
||||
return
|
||||
|
||||
|
@ -85,6 +86,7 @@ class LocalAuthorizationServer:
|
|||
except OSError:
|
||||
# OS error can happen if the socket was already closed. We really don't care about that case.
|
||||
pass
|
||||
Logger.log("d", "Local oauth2 web server was shut down")
|
||||
self._web_server = None
|
||||
self._web_server_thread = None
|
||||
|
||||
|
@ -96,12 +98,13 @@ class LocalAuthorizationServer:
|
|||
|
||||
:return: None
|
||||
"""
|
||||
Logger.log("d", "Local web server for authorization has started")
|
||||
if self._web_server:
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
self._web_server.serve_forever()
|
||||
except OSError as e:
|
||||
Logger.warning(str(e))
|
||||
except OSError:
|
||||
Logger.logException("w", "An exception happened while serving the auth server")
|
||||
else:
|
||||
# Leave the default behavior in non-windows platforms
|
||||
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.
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing import Optional, Dict, Any, List, Union
|
||||
from copy import deepcopy
|
||||
from cura.OAuth2.KeyringAttribute import KeyringAttribute
|
||||
|
||||
|
||||
class BaseModel:
|
||||
|
@ -37,12 +39,29 @@ class AuthenticationResponse(BaseModel):
|
|||
# Data comes from the token response with success flag and error message added.
|
||||
success = True # type: bool
|
||||
token_type = None # type: Optional[str]
|
||||
access_token = None # type: Optional[str]
|
||||
refresh_token = None # type: Optional[str]
|
||||
expires_in = None # type: Optional[str]
|
||||
scope = None # type: Optional[str]
|
||||
err_message = None # type: Optional[str]
|
||||
received_at = None # type: Optional[str]
|
||||
access_token = KeyringAttribute()
|
||||
refresh_token = KeyringAttribute()
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self.access_token = kwargs.pop("access_token", None)
|
||||
self.refresh_token = kwargs.pop("refresh_token", None)
|
||||
super(AuthenticationResponse, self).__init__(**kwargs)
|
||||
|
||||
def dump(self) -> Dict[str, Union[bool, Optional[str]]]:
|
||||
"""
|
||||
Dumps the dictionary of Authentication attributes. KeyringAttributes are transformed to public attributes
|
||||
If the keyring was used, these will have a None value, otherwise they will have the secret value
|
||||
|
||||
:return: Dictionary of Authentication attributes
|
||||
"""
|
||||
dumped = deepcopy(vars(self))
|
||||
dumped["access_token"] = dumped.pop("_access_token")
|
||||
dumped["refresh_token"] = dumped.pop("_refresh_token")
|
||||
return dumped
|
||||
|
||||
|
||||
class ResponseStatus(BaseModel):
|
||||
|
|
|
@ -79,7 +79,7 @@ class PickingPass(RenderPass):
|
|||
return -1
|
||||
|
||||
distance = output.pixel(px, py) # distance in micron, from in r, g & b channels
|
||||
distance = (distance & 0x00ffffff) / 1000. # drop the alpha channel and covert to mm
|
||||
distance = (distance & 0x00ffffff) / 1000. # drop the alpha channel and convert to mm
|
||||
return distance
|
||||
|
||||
def getPickedPosition(self, x: int, y: int) -> Vector:
|
||||
|
|
|
@ -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.
|
||||
|
||||
from typing import Optional, TYPE_CHECKING, cast, List
|
||||
|
@ -74,6 +74,7 @@ class PreviewPass(RenderPass):
|
|||
self._shader.setUniformValue("u_faceId", -1) # Don't render any selected faces in the preview.
|
||||
else:
|
||||
Logger.error("Unable to compile shader program: overhang.shader")
|
||||
return
|
||||
|
||||
if not self._non_printing_shader:
|
||||
self._non_printing_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader"))
|
||||
|
|
|
@ -49,7 +49,7 @@ class FirmwareUpdater(QObject):
|
|||
raise NotImplementedError("_updateFirmware needs to be implemented")
|
||||
|
||||
def _cleanupAfterUpdate(self) -> None:
|
||||
"""Cleanup after a succesful update"""
|
||||
"""Cleanup after a successful update"""
|
||||
|
||||
# Clean up for next attempt.
|
||||
self._update_firmware_thread = Thread(target=self._updateFirmware, daemon=True, name = "FirmwareUpdateThread")
|
||||
|
|
|
@ -414,6 +414,6 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
|||
|
||||
@pyqtProperty(str, constant = True)
|
||||
def ipAddress(self) -> str:
|
||||
"""IP adress of this printer"""
|
||||
"""IP address of this printer"""
|
||||
|
||||
return self._address
|
||||
|
|
|
@ -119,21 +119,23 @@ class CuraSceneNode(SceneNode):
|
|||
self._aabb = None
|
||||
if self._mesh_data:
|
||||
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():
|
||||
if child.callDecoration("isNonPrintingMesh"):
|
||||
# Non-printing-meshes inside a group should not affect push apart or drop to build plate
|
||||
continue
|
||||
if not child.getMeshData():
|
||||
# Nodes without mesh data should not affect bounding boxes of their parents.
|
||||
child_bb = child.getBoundingBox()
|
||||
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
|
||||
if self._aabb is None:
|
||||
self._aabb = child.getBoundingBox()
|
||||
self._aabb = child_bb
|
||||
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":
|
||||
"""Taken from SceneNode, but replaced SceneNode with CuraSceneNode"""
|
||||
|
@ -142,6 +144,7 @@ class CuraSceneNode(SceneNode):
|
|||
copy.setTransformation(self.getLocalTransformation(copy= False))
|
||||
copy.setMeshData(self._mesh_data)
|
||||
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._name = cast(str, deepcopy(self._name, memo))
|
||||
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.
|
||||
|
||||
import os
|
||||
|
@ -23,6 +23,8 @@ from UM.Settings.InstanceContainer import InstanceContainer
|
|||
|
||||
import cura.CuraApplication
|
||||
from cura.Machines.ContainerTree import ContainerTree
|
||||
from cura.Settings.ExtruderStack import ExtruderStack
|
||||
from cura.Settings.GlobalStack import GlobalStack
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
@ -241,6 +243,7 @@ class ContainerManager(QObject):
|
|||
file_url = file_url_or_string.toLocalFile()
|
||||
else:
|
||||
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):
|
||||
return {"status": "error", "message": "Invalid path"}
|
||||
|
@ -318,7 +321,7 @@ class ContainerManager(QObject):
|
|||
stack.qualityChanges = quality_changes
|
||||
|
||||
if not quality_changes or container_registry.isReadOnly(quality_changes.getId()):
|
||||
Logger.log("e", "Could not update quality of a nonexistant or read only quality profile in stack %s", stack.getId())
|
||||
Logger.log("e", "Could not update quality of a nonexistent or read only quality profile in stack %s", stack.getId())
|
||||
continue
|
||||
|
||||
self._performMerge(quality_changes, stack.getTop())
|
||||
|
@ -407,7 +410,7 @@ class ContainerManager(QObject):
|
|||
container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
|
||||
for plugin_id, container_type in container_registry.getContainerTypes():
|
||||
# Ignore default container types since those are not plugins
|
||||
if container_type in (InstanceContainer, ContainerStack, DefinitionContainer):
|
||||
if container_type in (InstanceContainer, ContainerStack, DefinitionContainer, GlobalStack, ExtruderStack):
|
||||
continue
|
||||
|
||||
serialize_type = ""
|
||||
|
|
|
@ -32,6 +32,10 @@ from cura.Machines.ContainerTree import ContainerTree
|
|||
from cura.ReaderWriters.ProfileReader import NoProfileException, ProfileReader
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
from .DatabaseHandlers.IntentDatabaseHandler import IntentDatabaseHandler
|
||||
from .DatabaseHandlers.QualityDatabaseHandler import QualityDatabaseHandler
|
||||
from .DatabaseHandlers.VariantDatabaseHandler import VariantDatabaseHandler
|
||||
|
||||
catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
|
@ -44,6 +48,10 @@ class CuraContainerRegistry(ContainerRegistry):
|
|||
# is added, we check to see if an extruder stack needs to be added.
|
||||
self.containerAdded.connect(self._onContainerAdded)
|
||||
|
||||
self._database_handlers["variant"] = VariantDatabaseHandler()
|
||||
self._database_handlers["quality"] = QualityDatabaseHandler()
|
||||
self._database_handlers["intent"] = IntentDatabaseHandler()
|
||||
|
||||
@override(ContainerRegistry)
|
||||
def addContainer(self, container: ContainerInterface) -> bool:
|
||||
"""Overridden from ContainerRegistry
|
||||
|
@ -141,20 +149,29 @@ class CuraContainerRegistry(ContainerRegistry):
|
|||
success = profile_writer.write(file_name, container_list)
|
||||
except Exception as e:
|
||||
Logger.log("e", "Failed to export profile to %s: %s", file_name, str(e))
|
||||
m = Message(catalog.i18nc("@info:status Don't translate the XML tags <filename> or <message>!", "Failed to export profile to <filename>{0}</filename>: <message>{1}</message>", file_name, str(e)),
|
||||
m = Message(catalog.i18nc("@info:status Don't translate the XML tags <filename> or <message>!",
|
||||
"Failed to export profile to <filename>{0}</filename>: <message>{1}</message>",
|
||||
file_name, str(e)),
|
||||
lifetime = 0,
|
||||
title = catalog.i18nc("@info:title", "Error"))
|
||||
title = catalog.i18nc("@info:title", "Error"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
m.show()
|
||||
return False
|
||||
if not success:
|
||||
Logger.log("w", "Failed to export profile to %s: Writer plugin reported failure.", file_name)
|
||||
m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Failed to export profile to <filename>{0}</filename>: Writer plugin reported failure.", file_name),
|
||||
m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!",
|
||||
"Failed to export profile to <filename>{0}</filename>: Writer plugin reported failure.",
|
||||
file_name),
|
||||
lifetime = 0,
|
||||
title = catalog.i18nc("@info:title", "Error"))
|
||||
title = catalog.i18nc("@info:title", "Error"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
m.show()
|
||||
return False
|
||||
m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Exported profile to <filename>{0}</filename>", file_name),
|
||||
title = catalog.i18nc("@info:title", "Export succeeded"))
|
||||
m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!",
|
||||
"Exported profile to <filename>{0}</filename>",
|
||||
file_name),
|
||||
title = catalog.i18nc("@info:title", "Export succeeded"),
|
||||
message_type = Message.MessageType.POSITIVE)
|
||||
m.show()
|
||||
return True
|
||||
|
||||
|
|
|
@ -66,7 +66,7 @@ class CuraStackBuilder:
|
|||
Logger.logException("e", "Failed to create an extruder stack for position {pos}: {err}".format(pos = position, err = str(e)))
|
||||
return None
|
||||
|
||||
# If given, set the machine_extruder_count when creating the machine, or else the extruderList used bellow will
|
||||
# If given, set the machine_extruder_count when creating the machine, or else the extruderList used below will
|
||||
# not return the correct extruder list (since by default, the machine_extruder_count is 1) in machines with
|
||||
# settable number of extruders.
|
||||
if machine_extruder_count and 0 <= machine_extruder_count <= len(extruder_dict):
|
||||
|
|
25
cura/Settings/DatabaseHandlers/IntentDatabaseHandler.py
Normal file
25
cura/Settings/DatabaseHandlers/IntentDatabaseHandler.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from UM.Settings.SQLQueryFactory import SQLQueryFactory
|
||||
from UM.Settings.DatabaseContainerMetadataController import DatabaseMetadataContainerController
|
||||
from UM.Settings.InstanceContainer import InstanceContainer
|
||||
|
||||
|
||||
class IntentDatabaseHandler(DatabaseMetadataContainerController):
|
||||
"""The Database handler for Intent containers"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(SQLQueryFactory(table = "intent",
|
||||
fields = {
|
||||
"id": "text",
|
||||
"name": "text",
|
||||
"quality_type": "text",
|
||||
"intent_category": "text",
|
||||
"variant": "text",
|
||||
"definition": "text",
|
||||
"material": "text",
|
||||
"version": "text",
|
||||
"setting_version": "text"
|
||||
}))
|
||||
self._container_type = InstanceContainer
|
38
cura/Settings/DatabaseHandlers/QualityDatabaseHandler.py
Normal file
38
cura/Settings/DatabaseHandlers/QualityDatabaseHandler.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from UM.Settings.SQLQueryFactory import SQLQueryFactory, metadata_type
|
||||
from UM.Settings.DatabaseContainerMetadataController import DatabaseMetadataContainerController
|
||||
from UM.Settings.InstanceContainer import InstanceContainer
|
||||
|
||||
|
||||
class QualityDatabaseHandler(DatabaseMetadataContainerController):
|
||||
"""The Database handler for Quality containers"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(SQLQueryFactory(table = "quality",
|
||||
fields = {
|
||||
"id": "text",
|
||||
"name": "text",
|
||||
"quality_type": "text",
|
||||
"material": "text",
|
||||
"variant": "text",
|
||||
"global_quality": "bool",
|
||||
"definition": "text",
|
||||
"version": "text",
|
||||
"setting_version": "text"
|
||||
}))
|
||||
self._container_type = InstanceContainer
|
||||
|
||||
def groomMetadata(self, metadata: metadata_type) -> metadata_type:
|
||||
"""
|
||||
Ensures that the metadata is in the order of the field keys and has the right size.
|
||||
if the metadata doesn't contains a key which is stored in the DB it will add it as
|
||||
an empty string. Key, value pairs that are not stored in the DB are dropped.
|
||||
If the `global_quality` isn't set it well default to 'False'
|
||||
|
||||
:param metadata: The container metadata
|
||||
"""
|
||||
if "global_quality" not in metadata:
|
||||
metadata["global_quality"] = "False"
|
||||
return super().groomMetadata(metadata)
|
22
cura/Settings/DatabaseHandlers/VariantDatabaseHandler.py
Normal file
22
cura/Settings/DatabaseHandlers/VariantDatabaseHandler.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from UM.Settings.SQLQueryFactory import SQLQueryFactory
|
||||
from UM.Settings.DatabaseContainerMetadataController import DatabaseMetadataContainerController
|
||||
from UM.Settings.InstanceContainer import InstanceContainer
|
||||
|
||||
|
||||
class VariantDatabaseHandler(DatabaseMetadataContainerController):
|
||||
"""The Database handler for Variant containers"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(SQLQueryFactory(table = "variant",
|
||||
fields = {
|
||||
"id": "text",
|
||||
"name": "text",
|
||||
"hardware_type": "text",
|
||||
"definition": "text",
|
||||
"version": "text",
|
||||
"setting_version": "text"
|
||||
}))
|
||||
self._container_type = InstanceContainer
|
0
cura/Settings/DatabaseHandlers/__init__.py
Normal file
0
cura/Settings/DatabaseHandlers/__init__.py
Normal file
|
@ -86,6 +86,14 @@ class GlobalStack(CuraContainerStack):
|
|||
def supportsNetworkConnection(self):
|
||||
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
|
||||
def getLoadingPriority(cls) -> int:
|
||||
return 2
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import time
|
||||
|
@ -627,7 +627,7 @@ class MachineManager(QObject):
|
|||
return ""
|
||||
return global_container_stack.getIntentCategory()
|
||||
|
||||
# Provies a list of extruder positions that have a different intent from the active one.
|
||||
# Provides a list of extruder positions that have a different intent from the active one.
|
||||
@pyqtProperty("QStringList", notify=activeIntentChanged)
|
||||
def extruderPositionsWithNonActiveIntent(self):
|
||||
global_container_stack = self._application.getGlobalContainerStack()
|
||||
|
@ -853,7 +853,8 @@ class MachineManager(QObject):
|
|||
self._global_container_stack.userChanges.setProperty(setting_key, "value", self._default_extruder_position)
|
||||
if add_user_changes:
|
||||
caution_message = Message(
|
||||
catalog.i18nc("@info:message Followed by a list of settings.", "Settings have been changed to match the current availability of extruders:") + " [{settings_list}]".format(settings_list = ", ".join(add_user_changes)),
|
||||
catalog.i18nc("@info:message Followed by a list of settings.",
|
||||
"Settings have been changed to match the current availability of extruders:") + " [{settings_list}]".format(settings_list = ", ".join(add_user_changes)),
|
||||
lifetime = 0,
|
||||
title = catalog.i18nc("@info:title", "Settings updated"))
|
||||
caution_message.show()
|
||||
|
@ -1397,6 +1398,8 @@ class MachineManager(QObject):
|
|||
# previous one).
|
||||
self._global_container_stack.setUserChanges(global_user_changes)
|
||||
for i, user_changes in enumerate(per_extruder_user_changes):
|
||||
if i >= len(self._global_container_stack.extruderList): # New printer has fewer extruders.
|
||||
break
|
||||
self._global_container_stack.extruderList[i].setUserChanges(per_extruder_user_changes[i])
|
||||
|
||||
@pyqtSlot(QObject)
|
||||
|
|
|
@ -18,6 +18,8 @@ class SingleInstance:
|
|||
|
||||
self._single_instance_server = None
|
||||
|
||||
self._application.getPreferences().addPreference("cura/single_instance_clear_before_load", True)
|
||||
|
||||
# Starts a client that checks for a single instance server and sends the files that need to opened if the server
|
||||
# exists. Returns True if the single instance server is found, otherwise False.
|
||||
def startClient(self) -> bool:
|
||||
|
@ -42,8 +44,9 @@ class SingleInstance:
|
|||
# "command" field is required and holds the name of the command to execute.
|
||||
# Other fields depend on the command.
|
||||
|
||||
payload = {"command": "clear-all"}
|
||||
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii"))
|
||||
if self._application.getPreferences().getValue("cura/single_instance_clear_before_load"):
|
||||
payload = {"command": "clear-all"}
|
||||
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii"))
|
||||
|
||||
payload = {"command": "focus"}
|
||||
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii"))
|
||||
|
@ -68,7 +71,7 @@ class SingleInstance:
|
|||
Logger.log("e", "Single instance server was not created.")
|
||||
|
||||
def _onClientConnected(self) -> None:
|
||||
Logger.log("i", "New connection recevied on our single-instance server")
|
||||
Logger.log("i", "New connection received on our single-instance server")
|
||||
connection = None #type: Optional[QLocalSocket]
|
||||
if self._single_instance_server:
|
||||
connection = self._single_instance_server.nextPendingConnection()
|
||||
|
|
|
@ -25,8 +25,8 @@ class Snapshot:
|
|||
pixels = numpy.frombuffer(pixel_array, dtype=numpy.uint8).reshape([height, width, 4])
|
||||
# Find indices of non zero pixels
|
||||
nonzero_pixels = numpy.nonzero(pixels)
|
||||
min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1)
|
||||
max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1)
|
||||
min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1) # type: ignore
|
||||
max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1) # type: ignore
|
||||
|
||||
return min_x, max_x, min_y, max_y
|
||||
|
||||
|
@ -93,7 +93,7 @@ class Snapshot:
|
|||
pixel_output = preview_pass.getOutput()
|
||||
try:
|
||||
min_x, max_x, min_y, max_y = Snapshot.getImageBoundaries(pixel_output)
|
||||
except ValueError:
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
size = max((max_x - min_x) / render_width, (max_y - min_y) / render_height)
|
||||
|
|
|
@ -56,8 +56,8 @@ class OnExitCallbackManager:
|
|||
self._application.callLater(self._application.closeApplication)
|
||||
|
||||
# This is the callback function which an on-exit callback should call when it finishes, it should provide the
|
||||
# "should_proceed" flag indicating whether this check has "passed", or in other words, whether quiting the
|
||||
# application should be blocked. If the last on-exit callback doesn't block the quiting, it will call the next
|
||||
# "should_proceed" flag indicating whether this check has "passed", or in other words, whether quitting the
|
||||
# application should be blocked. If the last on-exit callback doesn't block the quitting, it will call the next
|
||||
# registered on-exit callback if available.
|
||||
def onCurrentCallbackFinished(self, should_proceed: bool = True) -> None:
|
||||
if not should_proceed:
|
||||
|
|
|
@ -90,7 +90,7 @@ class ObjectsModel(ListModel):
|
|||
|
||||
parent = node.getParent()
|
||||
if parent and parent.callDecoration("isGroup"):
|
||||
return False # Grouped nodes don't need resetting as their parent (the group) is resetted)
|
||||
return False # Grouped nodes don't need resetting as their parent (the group) is reset)
|
||||
|
||||
node_build_plate_number = node.callDecoration("getBuildPlateNumber")
|
||||
if Application.getInstance().getPreferences().getValue("view/filter_current_build_plate") and node_build_plate_number != self._build_plate_number:
|
||||
|
@ -132,9 +132,26 @@ class ObjectsModel(ListModel):
|
|||
|
||||
is_group = bool(node.callDecoration("isGroup"))
|
||||
|
||||
name_handled_as_group = False
|
||||
force_rename = False
|
||||
if not is_group:
|
||||
# Handle names for individual nodes
|
||||
if is_group:
|
||||
# Handle names for grouped nodes
|
||||
original_name = self._group_name_prefix
|
||||
|
||||
current_name = node.getName()
|
||||
if current_name.startswith(self._group_name_prefix):
|
||||
# This group has a standard group name, but we may need to renumber it
|
||||
name_index = int(current_name.split("#")[-1])
|
||||
name_handled_as_group = True
|
||||
elif not current_name:
|
||||
# Force rename this group because this node has not been named as a group yet, probably because
|
||||
# it's a newly created group.
|
||||
name_index = 0
|
||||
force_rename = True
|
||||
name_handled_as_group = True
|
||||
|
||||
if not is_group or not name_handled_as_group:
|
||||
# Handle names for individual nodes or groups that already have a non-group name
|
||||
name = node.getName()
|
||||
|
||||
name_match = self._naming_regex.fullmatch(name)
|
||||
|
@ -144,18 +161,6 @@ class ObjectsModel(ListModel):
|
|||
else:
|
||||
original_name = name_match.groups()[0]
|
||||
name_index = int(name_match.groups()[1])
|
||||
else:
|
||||
# Handle names for grouped nodes
|
||||
original_name = self._group_name_prefix
|
||||
|
||||
current_name = node.getName()
|
||||
if current_name.startswith(self._group_name_prefix):
|
||||
name_index = int(current_name.split("#")[-1])
|
||||
else:
|
||||
# Force rename this group because this node has not been named as a group yet, probably because
|
||||
# it's a newly created group.
|
||||
name_index = 0
|
||||
force_rename = True
|
||||
|
||||
if original_name not in name_to_node_info_dict:
|
||||
# Keep track of 2 things:
|
||||
|
|
|
@ -13,6 +13,8 @@ from UM.Qt.Duration import Duration
|
|||
from UM.Scene.SceneNode import SceneNode
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.MimeTypeDatabase import MimeTypeDatabase, MimeTypeNotFoundError
|
||||
from UM.OutputDevice.OutputDevice import OutputDevice
|
||||
from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
@ -68,6 +70,7 @@ class PrintInformation(QObject):
|
|||
self._application.globalContainerStackChanged.connect(self.setToZeroPrintInformation)
|
||||
self._application.fileLoaded.connect(self.setBaseName)
|
||||
self._application.workspaceLoaded.connect(self.setProjectName)
|
||||
self._application.getOutputDeviceManager().writeStarted.connect(self._onOutputStart)
|
||||
self._application.getMachineManager().rootMaterialChanged.connect(self._onActiveMaterialsChanged)
|
||||
self._application.getInstance().getPreferences().preferenceChanged.connect(self._onPreferencesChanged)
|
||||
|
||||
|
@ -439,3 +442,11 @@ class PrintInformation(QObject):
|
|||
"""Listen to scene changes to check if we need to reset the print information"""
|
||||
|
||||
self.setToZeroPrintInformation(self._active_build_plate)
|
||||
|
||||
def _onOutputStart(self, output_device: OutputDevice) -> None:
|
||||
"""If this is the sort of output 'device' (like local or online file storage, rather than a printer),
|
||||
the user could have altered the file-name, and thus the project name should be altered as well."""
|
||||
if isinstance(output_device, ProjectOutputDevice):
|
||||
new_name = output_device.getLastOutputName()
|
||||
if new_name is not None:
|
||||
self.setJobName(os.path.splitext(os.path.basename(new_name))[0])
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import collections
|
||||
|
@ -6,9 +6,11 @@ from typing import Optional, Dict, List, cast
|
|||
|
||||
from PyQt5.QtCore import QObject, pyqtSlot
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Resources import Resources
|
||||
from UM.Version import Version
|
||||
|
||||
catalog = i18nCatalog("cura")
|
||||
|
||||
#
|
||||
# This manager provides means to load texts to QML.
|
||||
|
@ -30,30 +32,33 @@ class TextManager(QObject):
|
|||
# Load change log texts and organize them with a dict
|
||||
try:
|
||||
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.
|
||||
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]]]
|
||||
with open(file_path, "r", encoding = "utf-8") as f:
|
||||
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
|
||||
for line in f:
|
||||
line = line.replace("\n", "")
|
||||
if "[" in line and "]" in line:
|
||||
line = line.replace("[", "")
|
||||
line = line.replace("]", "")
|
||||
open_version = Version(line)
|
||||
if open_version > Version([14, 99, 99]): # Bit of a hack: We released the 15.x.x versions before 2.x
|
||||
open_version = Version([0, open_version.getMinor(), open_version.getRevision(), open_version.getPostfixVersion()])
|
||||
open_header = ""
|
||||
change_logs_dict[open_version] = collections.OrderedDict()
|
||||
elif line.startswith("*"):
|
||||
open_header = line.replace("*", "")
|
||||
change_logs_dict[cast(Version, open_version)][open_header] = []
|
||||
elif line != "":
|
||||
if open_header not in change_logs_dict[cast(Version, open_version)]:
|
||||
try:
|
||||
with open(file_path, "r", encoding = "utf-8") as f:
|
||||
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
|
||||
for line in f:
|
||||
line = line.replace("\n", "")
|
||||
if "[" in line and "]" in line:
|
||||
line = line.replace("[", "")
|
||||
line = line.replace("]", "")
|
||||
open_version = Version(line)
|
||||
if open_version > Version([14, 99, 99]): # Bit of a hack: We released the 15.x.x versions before 2.x
|
||||
open_version = Version([0, open_version.getMinor(), open_version.getRevision(), open_version.getPostfixVersion()])
|
||||
open_header = ""
|
||||
change_logs_dict[open_version] = collections.OrderedDict()
|
||||
elif line.startswith("*"):
|
||||
open_header = line.replace("*", "")
|
||||
change_logs_dict[cast(Version, open_version)][open_header] = []
|
||||
change_logs_dict[cast(Version, open_version)][open_header].append(line)
|
||||
elif line != "":
|
||||
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].append(line)
|
||||
except EnvironmentError as e:
|
||||
return catalog.i18nc("@text:window", "The release notes could not be opened.") + "<br>" + str(e)
|
||||
|
||||
# Format changelog text
|
||||
content = ""
|
||||
|
|
|
@ -29,7 +29,7 @@ class WhatsNewPagesModel(WelcomePagesModel):
|
|||
for filename in files:
|
||||
basename = os.path.basename(filename)
|
||||
base, ext = os.path.splitext(basename)
|
||||
if ext not in include or not base.isdigit():
|
||||
if ext.lower() not in include or not base.isdigit():
|
||||
continue
|
||||
page_no = int(base)
|
||||
highest = max(highest, page_no)
|
||||
|
|
|
@ -16,14 +16,6 @@ import argparse
|
|||
import faulthandler
|
||||
import os
|
||||
|
||||
# Workaround for a race condition on certain systems where there
|
||||
# is a race condition between Arcus and PyQt. Importing Arcus
|
||||
# first seems to prevent Sip from going into a state where it
|
||||
# tries to create PyQt objects on a non-main thread.
|
||||
import Arcus # @UnusedImport
|
||||
import Savitar # @UnusedImport
|
||||
import pynest2d # @UnusedImport
|
||||
|
||||
from PyQt5.QtNetwork import QSslConfiguration, QSslSocket
|
||||
|
||||
from UM.Platform import Platform
|
||||
|
|
|
@ -7,7 +7,7 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
|||
PROJECT_DIR="$( cd "${SCRIPT_DIR}/.." && pwd )"
|
||||
|
||||
# Make sure that environment variables are set properly
|
||||
source /opt/rh/devtoolset-7/enable
|
||||
source /opt/rh/devtoolset-8/enable
|
||||
export PATH="${CURA_BUILD_ENV_PATH}/bin:${PATH}"
|
||||
export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}"
|
||||
|
||||
|
@ -50,7 +50,7 @@ do
|
|||
echo "Found Uranium branch [${URANIUM_BRANCH}]."
|
||||
break
|
||||
else
|
||||
echo "Could not find Uranium banch [${URANIUM_BRANCH}], try next."
|
||||
echo "Could not find Uranium branch [${URANIUM_BRANCH}], try next."
|
||||
fi
|
||||
done
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ The build volume draws a cube (for rectangular build plates) that represents the
|
|||
|
||||
The build volume also draws a grid underneath the build volume. The grid features 1cm lines which allows the user to roughly estimate how big its print is or the distance between prints. It also features a finer 1mm line pattern within that grid. The grid is drawn as a single quad. This quad is then sent to the graphical card with a specialised shader which draws the grid pattern.
|
||||
|
||||
For elliptical build plates, the volume bounds are drawn as two circles, one at the top and one at the bottom of the available height. The build plate grid is drawn as a tesselated circle, but with the same shader.
|
||||
For elliptical build plates, the volume bounds are drawn as two circles, one at the top and one at the bottom of the available height. The build plate grid is drawn as a tessellated circle, but with the same shader.
|
||||
|
||||
Disallowed areas
|
||||
----
|
||||
|
|
|
@ -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.
|
||||
|
||||
from configparser import ConfigParser
|
||||
|
@ -412,7 +412,12 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
|
|||
quality_container_id = parser["containers"][str(_ContainerIndexes.Quality)]
|
||||
quality_type = "empty_quality"
|
||||
if quality_container_id not in ("empty", "empty_quality"):
|
||||
quality_type = instance_container_info_dict[quality_container_id].parser["metadata"]["quality_type"]
|
||||
if quality_container_id in instance_container_info_dict:
|
||||
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
|
||||
serialized = archive.open(global_stack_file).read().decode("utf-8")
|
||||
|
@ -535,7 +540,8 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
|
|||
"Project file <filename>{0}</filename> contains an unknown machine type"
|
||||
" <message>{1}</message>. Cannot import the machine."
|
||||
" Models will be imported instead.", file_name, machine_definition_id),
|
||||
title = i18n_catalog.i18nc("@info:title", "Open Project File"))
|
||||
title = i18n_catalog.i18nc("@info:title", "Open Project File"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
message.show()
|
||||
|
||||
Logger.log("i", "Could unknown machine definition %s in project file %s, cannot import it.",
|
||||
|
@ -632,14 +638,16 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
|
|||
except EnvironmentError as e:
|
||||
message = Message(i18n_catalog.i18nc("@info:error Don't translate the XML tags <filename> or <message>!",
|
||||
"Project file <filename>{0}</filename> is suddenly inaccessible: <message>{1}</message>.", file_name, str(e)),
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"))
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
message.show()
|
||||
self.setWorkspaceName("")
|
||||
return [], {}
|
||||
except zipfile.BadZipFile as e:
|
||||
message = Message(i18n_catalog.i18nc("@info:error Don't translate the XML tags <filename> or <message>!",
|
||||
"Project file <filename>{0}</filename> is corrupt: <message>{1}</message>.", file_name, str(e)),
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"))
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
message.show()
|
||||
self.setWorkspaceName("")
|
||||
return [], {}
|
||||
|
@ -691,7 +699,8 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
|
|||
if not global_stacks:
|
||||
message = Message(i18n_catalog.i18nc("@info:error Don't translate the XML tag <filename>!",
|
||||
"Project file <filename>{0}</filename> is made using profiles that"
|
||||
" are unknown to this version of Ultimaker Cura.", file_name))
|
||||
" are unknown to this version of Ultimaker Cura.", file_name),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
message.show()
|
||||
self.setWorkspaceName("")
|
||||
return [], {}
|
||||
|
@ -1157,7 +1166,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
|
|||
return
|
||||
machine_manager.setQualityChangesGroup(quality_changes_group, no_dialog = True)
|
||||
else:
|
||||
self._quality_type_to_apply = self._quality_type_to_apply.lower()
|
||||
self._quality_type_to_apply = self._quality_type_to_apply.lower() if self._quality_type_to_apply else None
|
||||
quality_group_dict = container_tree.getCurrentQualityGroups()
|
||||
if self._quality_type_to_apply in quality_group_dict:
|
||||
quality_group = quality_group_dict[self._quality_type_to_apply]
|
||||
|
|
|
@ -419,7 +419,7 @@ UM.Dialog
|
|||
width: warningLabel.height
|
||||
height: width
|
||||
|
||||
source: UM.Theme.getIcon("notice")
|
||||
source: UM.Theme.getIcon("Information")
|
||||
color: palette.text
|
||||
|
||||
}
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
"author": "Ultimaker B.V.",
|
||||
"version": "1.0.1",
|
||||
"description": "Provides support for reading 3MF files.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
"author": "Ultimaker B.V.",
|
||||
"version": "1.0.1",
|
||||
"description": "Provides support for writing 3MF files.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
|
|
@ -157,22 +157,22 @@ class AMFReader(MeshReader):
|
|||
tri_faces = tri_node.faces
|
||||
tri_vertices = tri_node.vertices
|
||||
|
||||
indices = []
|
||||
vertices = []
|
||||
indices_list = []
|
||||
vertices_list = []
|
||||
|
||||
index_count = 0
|
||||
face_count = 0
|
||||
for tri_face in tri_faces:
|
||||
face = []
|
||||
for tri_index in tri_face:
|
||||
vertices.append(tri_vertices[tri_index])
|
||||
vertices_list.append(tri_vertices[tri_index])
|
||||
face.append(index_count)
|
||||
index_count += 1
|
||||
indices.append(face)
|
||||
indices_list.append(face)
|
||||
face_count += 1
|
||||
|
||||
vertices = numpy.asarray(vertices, dtype = numpy.float32)
|
||||
indices = numpy.asarray(indices, dtype = numpy.int32)
|
||||
vertices = numpy.asarray(vertices_list, dtype = numpy.float32)
|
||||
indices = numpy.asarray(indices_list, dtype = numpy.int32)
|
||||
normals = calculateNormalsFromIndexedVertices(vertices, indices, face_count)
|
||||
|
||||
mesh_data = MeshData(vertices = vertices, indices = indices, normals = normals,file_name = file_name)
|
||||
|
|
|
@ -3,5 +3,5 @@
|
|||
"author": "fieldOfView",
|
||||
"version": "1.0.0",
|
||||
"description": "Provides support for reading AMF files.",
|
||||
"api": "7.4.0"
|
||||
"api": 7
|
||||
}
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
"author": "Ultimaker B.V.",
|
||||
"description": "Backup and restore your configuration.",
|
||||
"version": "1.2.0",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ import threading
|
|||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import sentry_sdk
|
||||
from PyQt5.QtNetwork import QNetworkReply
|
||||
|
||||
from UM.Job import Job
|
||||
|
@ -44,7 +43,9 @@ class CreateBackupJob(Job):
|
|||
"""After the job completes, an empty string indicates success. Othrerwise, the value is a translated message."""
|
||||
|
||||
def run(self) -> None:
|
||||
upload_message = Message(catalog.i18nc("@info:backup_status", "Creating your backup..."), title = self.MESSAGE_TITLE, progress = -1)
|
||||
upload_message = Message(catalog.i18nc("@info:backup_status", "Creating your backup..."),
|
||||
title = self.MESSAGE_TITLE,
|
||||
progress = -1)
|
||||
upload_message.show()
|
||||
CuraApplication.getInstance().processEvents()
|
||||
cura_api = CuraApplication.getInstance().getCuraAPI()
|
||||
|
@ -99,13 +100,7 @@ class CreateBackupJob(Job):
|
|||
if HttpRequestManager.safeHttpStatus(reply) == 400:
|
||||
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 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.")
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
self._job_done.set()
|
||||
return
|
||||
|
|
|
@ -93,7 +93,7 @@ class DriveApiService:
|
|||
def _onRestoreFinished(self, job: "RestoreBackupJob") -> None:
|
||||
if job.restore_backup_error_message != "":
|
||||
# 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:
|
||||
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).
|
||||
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"
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
@ -45,6 +48,7 @@ class DrivePluginExtension(QObject, Extension):
|
|||
self._backups = [] # type: List[Dict[str, Any]]
|
||||
self._is_restoring_backup = False
|
||||
self._is_creating_backup = False
|
||||
self._backup_id_being_restored = ""
|
||||
|
||||
# Initialize services.
|
||||
preferences = CuraApplication.getInstance().getPreferences()
|
||||
|
@ -52,6 +56,7 @@ class DrivePluginExtension(QObject, Extension):
|
|||
|
||||
# Attach signals.
|
||||
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.creatingStateChanged.connect(self._onCreatingStateChanged)
|
||||
|
||||
|
@ -75,6 +80,10 @@ class DrivePluginExtension(QObject, Extension):
|
|||
if self._drive_window:
|
||||
self._drive_window.show()
|
||||
|
||||
def _onApplicationShuttingDown(self):
|
||||
if self._drive_window:
|
||||
self._drive_window.hide()
|
||||
|
||||
def _autoBackup(self) -> None:
|
||||
preferences = CuraApplication.getInstance().getPreferences()
|
||||
if preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY) and self._isLastBackupTooLongAgo():
|
||||
|
@ -100,17 +109,22 @@ class DrivePluginExtension(QObject, Extension):
|
|||
if logged_in:
|
||||
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.restoringStateChanged.emit()
|
||||
if error_message:
|
||||
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
|
||||
self.backupIdBeingRestored = ""
|
||||
Message(error_message,
|
||||
title = catalog.i18nc("@info:title", "Backup"),
|
||||
message_type = Message.MessageType.ERROR).show()
|
||||
|
||||
def _onCreatingStateChanged(self, is_creating: bool = False, error_message: str = None) -> None:
|
||||
self._is_creating_backup = is_creating
|
||||
self.creatingStateChanged.emit()
|
||||
if error_message:
|
||||
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
|
||||
Message(error_message,
|
||||
title = catalog.i18nc("@info:title", "Backup"),
|
||||
message_type = Message.MessageType.ERROR).show()
|
||||
else:
|
||||
self._storeBackupDate()
|
||||
if not is_creating and not error_message:
|
||||
|
@ -152,6 +166,7 @@ class DrivePluginExtension(QObject, Extension):
|
|||
for backup in self._backups:
|
||||
if backup.get("backup_id") == backup_id:
|
||||
self._drive_api_service.restoreBackup(backup)
|
||||
self.setBackupIdBeingRestored(backup_id)
|
||||
return
|
||||
Logger.log("w", "Unable to find backup with the ID %s", backup_id)
|
||||
|
||||
|
@ -166,3 +181,12 @@ class DrivePluginExtension(QObject, Extension):
|
|||
def _backupDeletedCallback(self, success: bool):
|
||||
if success:
|
||||
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 hashlib
|
||||
import threading
|
||||
|
@ -56,14 +59,20 @@ class RestoreBackupJob(Job):
|
|||
return
|
||||
|
||||
# We store the file in a temporary path fist to ensure integrity.
|
||||
temporary_backup_file = NamedTemporaryFile(delete = False)
|
||||
with open(temporary_backup_file.name, "wb") as write_backup:
|
||||
app = CuraApplication.getInstance()
|
||||
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
|
||||
while bytes_read:
|
||||
write_backup.write(bytes_read)
|
||||
try:
|
||||
temporary_backup_file = NamedTemporaryFile(delete = False)
|
||||
with open(temporary_backup_file.name, "wb") as write_backup:
|
||||
app = CuraApplication.getInstance()
|
||||
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
|
||||
app.processEvents()
|
||||
while bytes_read:
|
||||
write_backup.write(bytes_read)
|
||||
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
|
||||
app.processEvents()
|
||||
except EnvironmentError as e:
|
||||
Logger.log("e", f"Unable to save backed up files due to computer limitations: {str(e)}")
|
||||
self.restore_backup_error_message = self.DEFAULT_ERROR_MESSAGE
|
||||
self._job_done.set()
|
||||
return
|
||||
|
||||
if not self._verifyMd5Hash(temporary_backup_file.name, self._backup.get("md5_hash", "")):
|
||||
# Don't restore the backup if the MD5 hashes do not match.
|
||||
|
|
|
@ -20,7 +20,7 @@ RowLayout
|
|||
{
|
||||
id: infoButton
|
||||
text: catalog.i18nc("@button", "Want more?")
|
||||
iconSource: UM.Theme.getIcon("info")
|
||||
iconSource: UM.Theme.getIcon("Information")
|
||||
onClicked: Qt.openUrlExternally("https://goo.gl/forms/QACEP8pP3RV60QYG2")
|
||||
visible: backupListFooter.showInfoButton
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ RowLayout
|
|||
{
|
||||
id: createBackupButton
|
||||
text: catalog.i18nc("@button", "Backup Now")
|
||||
iconSource: UM.Theme.getIcon("plus")
|
||||
iconSource: UM.Theme.getIcon("Plus")
|
||||
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
|
||||
onClicked: CuraDrive.createBackup()
|
||||
busy: CuraDrive.isCreatingBackup
|
||||
|
|
|
@ -38,7 +38,7 @@ Item
|
|||
height: UM.Theme.getSize("section_icon").height
|
||||
color: UM.Theme.getColor("small_button_text")
|
||||
hoverColor: UM.Theme.getColor("small_button_text_hover")
|
||||
iconSource: UM.Theme.getIcon("info")
|
||||
iconSource: UM.Theme.getIcon("Information")
|
||||
onClicked: backupListItem.showDetails = !backupListItem.showDetails
|
||||
}
|
||||
|
||||
|
@ -71,6 +71,7 @@ Item
|
|||
text: catalog.i18nc("@button", "Restore")
|
||||
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
|
||||
onClicked: confirmRestoreDialog.visible = true
|
||||
busy: CuraDrive.backupIdBeingRestored == modelData.backup_id && CuraDrive.isRestoringBackup
|
||||
}
|
||||
|
||||
UM.SimpleButton
|
||||
|
@ -79,7 +80,7 @@ Item
|
|||
height: UM.Theme.getSize("message_close").height
|
||||
color: UM.Theme.getColor("small_button_text")
|
||||
hoverColor: UM.Theme.getColor("small_button_text_hover")
|
||||
iconSource: UM.Theme.getIcon("cross1")
|
||||
iconSource: UM.Theme.getIcon("Cancel")
|
||||
onClicked: confirmDeleteDialog.visible = true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright (c) 2018 Ultimaker B.V.
|
||||
// Copyright (c) 2021 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.7
|
||||
|
@ -17,7 +17,7 @@ ColumnLayout
|
|||
// Cura version
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("application")
|
||||
iconSource: UM.Theme.getIcon("UltimakerCura")
|
||||
label: catalog.i18nc("@backuplist:label", "Cura Version")
|
||||
value: backupDetailsData.metadata.cura_release
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ ColumnLayout
|
|||
// Machine count.
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("printer_single")
|
||||
iconSource: UM.Theme.getIcon("Printer")
|
||||
label: catalog.i18nc("@backuplist:label", "Machines")
|
||||
value: backupDetailsData.metadata.machine_count
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ ColumnLayout
|
|||
// Material count
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("category_material")
|
||||
iconSource: UM.Theme.getIcon("Spool")
|
||||
label: catalog.i18nc("@backuplist:label", "Materials")
|
||||
value: backupDetailsData.metadata.material_count
|
||||
}
|
||||
|
@ -41,7 +41,7 @@ ColumnLayout
|
|||
// Profile count.
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("settings")
|
||||
iconSource: UM.Theme.getIcon("Sliders")
|
||||
label: catalog.i18nc("@backuplist:label", "Profiles")
|
||||
value: backupDetailsData.metadata.profile_count
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ ColumnLayout
|
|||
// Plugin count.
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("plugin")
|
||||
iconSource: UM.Theme.getIcon("Plugin")
|
||||
label: catalog.i18nc("@backuplist:label", "Plugins")
|
||||
value: backupDetailsData.metadata.plugin_count
|
||||
}
|
||||
|
|
|
@ -4,12 +4,12 @@
|
|||
import argparse #To run the engine in debug mode if the front-end is in debug mode.
|
||||
from collections import defaultdict
|
||||
import os
|
||||
from PyQt5.QtCore import QObject, QTimer, pyqtSlot
|
||||
from PyQt5.QtCore import QObject, QTimer, QUrl, pyqtSlot
|
||||
import sys
|
||||
from time import time
|
||||
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.Scene.SceneNode import SceneNode
|
||||
|
@ -157,6 +157,19 @@ class CuraEngineBackend(QObject, Backend):
|
|||
self.determineAutoSlicing()
|
||||
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"),
|
||||
message_type = Message.MessageType.ERROR
|
||||
)
|
||||
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]
|
||||
|
||||
application.initializationFinished.connect(self.initialize)
|
||||
|
@ -379,7 +392,9 @@ class CuraEngineBackend(QObject, Backend):
|
|||
if job.getResult() == StartJobResult.MaterialIncompatible:
|
||||
if application.platformActivity:
|
||||
self._error_message = Message(catalog.i18nc("@info:status",
|
||||
"Unable to slice with the current material as it is incompatible with the selected machine or configuration."), title = catalog.i18nc("@info:title", "Unable to slice"))
|
||||
"Unable to slice with the current material as it is incompatible with the selected machine or configuration."),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
self._error_message.show()
|
||||
self.setState(BackendState.Error)
|
||||
self.backendError.emit(job)
|
||||
|
@ -409,8 +424,11 @@ class CuraEngineBackend(QObject, Backend):
|
|||
continue
|
||||
error_labels.add(definitions[0].label)
|
||||
|
||||
self._error_message = Message(catalog.i18nc("@info:status", "Unable to slice with the current settings. The following settings have errors: {0}").format(", ".join(error_labels)),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"))
|
||||
self._error_message = Message(catalog.i18nc("@info:status",
|
||||
"Unable to slice with the current settings. The following settings have errors: {0}").format(", ".join(error_labels)),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
Logger.warning(f"Unable to slice with the current settings. The following settings have errors: {', '.join(error_labels)}")
|
||||
self._error_message.show()
|
||||
self.setState(BackendState.Error)
|
||||
self.backendError.emit(job)
|
||||
|
@ -433,8 +451,11 @@ class CuraEngineBackend(QObject, Backend):
|
|||
Logger.log("e", "When checking settings for errors, unable to find definition for key {key} in per-object stack.".format(key = key))
|
||||
continue
|
||||
errors[key] = definition[0].label
|
||||
self._error_message = Message(catalog.i18nc("@info:status", "Unable to slice due to some per-model settings. The following settings have errors on one or more models: {error_labels}").format(error_labels = ", ".join(errors.values())),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"))
|
||||
self._error_message = Message(catalog.i18nc("@info:status",
|
||||
"Unable to slice due to some per-model settings. The following settings have errors on one or more models: {error_labels}").format(error_labels = ", ".join(errors.values())),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
Logger.warning(f"Unable to slice due to per-object settings. The following settings have errors on one or more models: {', '.join(errors.values())}")
|
||||
self._error_message.show()
|
||||
self.setState(BackendState.Error)
|
||||
self.backendError.emit(job)
|
||||
|
@ -442,17 +463,22 @@ class CuraEngineBackend(QObject, Backend):
|
|||
|
||||
if job.getResult() == StartJobResult.BuildPlateError:
|
||||
if application.platformActivity:
|
||||
self._error_message = Message(catalog.i18nc("@info:status", "Unable to slice because the prime tower or prime position(s) are invalid."),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"))
|
||||
self._error_message = Message(catalog.i18nc("@info:status",
|
||||
"Unable to slice because the prime tower or prime position(s) are invalid."),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
self._error_message.show()
|
||||
self.setState(BackendState.Error)
|
||||
self.backendError.emit(job)
|
||||
return
|
||||
else:
|
||||
self.setState(BackendState.NotStarted)
|
||||
|
||||
if job.getResult() == StartJobResult.ObjectsWithDisabledExtruder:
|
||||
self._error_message = Message(catalog.i18nc("@info:status", "Unable to slice because there are objects associated with disabled Extruder %s.") % job.getMessage(),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"))
|
||||
self._error_message = Message(catalog.i18nc("@info:status",
|
||||
"Unable to slice because there are objects associated with disabled Extruder %s.") % job.getMessage(),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
self._error_message.show()
|
||||
self.setState(BackendState.Error)
|
||||
self.backendError.emit(job)
|
||||
|
@ -464,7 +490,8 @@ class CuraEngineBackend(QObject, Backend):
|
|||
"\n- Fit within the build volume"
|
||||
"\n- Are assigned to an enabled extruder"
|
||||
"\n- Are not all set as modifier meshes"),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"))
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
self._error_message.show()
|
||||
self.setState(BackendState.Error)
|
||||
self.backendError.emit(job)
|
||||
|
@ -622,7 +649,7 @@ class CuraEngineBackend(QObject, Backend):
|
|||
for node in DepthFirstIterator(self._scene.getRoot()):
|
||||
if node.callDecoration("getLayerData"):
|
||||
if not build_plate_numbers or node.callDecoration("getBuildPlateNumber") in build_plate_numbers:
|
||||
# We can asume that all nodes have a parent as we're looping through the scene (and filter out root)
|
||||
# We can assume that all nodes have a parent as we're looping through the scene (and filter out root)
|
||||
cast(SceneNode, node.getParent()).removeChild(node)
|
||||
|
||||
def markSliceAll(self) -> None:
|
||||
|
@ -922,9 +949,22 @@ class CuraEngineBackend(QObject, Backend):
|
|||
|
||||
if not self._restart:
|
||||
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
|
||||
|
||||
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:
|
||||
"""Called when the global container stack changes"""
|
||||
|
||||
|
|
|
@ -257,10 +257,10 @@ class ProcessSlicedLayersJob(Job):
|
|||
if self.isRunning():
|
||||
if Application.getInstance().getController().getActiveView().getPluginId() == "SimulationView":
|
||||
if not self._progress_message:
|
||||
self._progress_message = Message(catalog.i18nc("@info:status", "Processing Layers"), 0, False, 0, catalog.i18nc("@info:title", "Information"))
|
||||
self._progress_message = Message(catalog.i18nc("@info:status", "Processing Layers"), 0, False, 0,
|
||||
catalog.i18nc("@info:title", "Information"))
|
||||
if self._progress_message.getProgress() != 100:
|
||||
self._progress_message.show()
|
||||
else:
|
||||
if self._progress_message:
|
||||
self._progress_message.hide()
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import numpy
|
||||
|
@ -195,7 +195,7 @@ class StartSliceJob(Job):
|
|||
# Remove old layer data.
|
||||
for node in DepthFirstIterator(self._scene.getRoot()):
|
||||
if node.callDecoration("getLayerData") and node.callDecoration("getBuildPlateNumber") == self._build_plate_number:
|
||||
# Singe we walk through all nodes in the scene, they always have a parent.
|
||||
# Since we walk through all nodes in the scene, they always have a parent.
|
||||
cast(SceneNode, node.getParent()).removeChild(node)
|
||||
break
|
||||
|
||||
|
@ -353,10 +353,19 @@ class StartSliceJob(Job):
|
|||
result[key] = stack.getProperty(key, "value")
|
||||
Job.yieldThread()
|
||||
|
||||
result["print_bed_temperature"] = result["material_bed_temperature"] # Renamed settings.
|
||||
# Material identification in addition to non-human-readable GUID
|
||||
result["material_id"] = stack.material.getMetaDataEntry("base_file", "")
|
||||
result["material_type"] = stack.material.getMetaDataEntry("material", "")
|
||||
result["material_name"] = stack.material.getMetaDataEntry("name", "")
|
||||
result["material_brand"] = stack.material.getMetaDataEntry("brand", "")
|
||||
|
||||
# Renamed settings.
|
||||
result["print_bed_temperature"] = result["material_bed_temperature"]
|
||||
result["print_temperature"] = result["material_print_temperature"]
|
||||
result["travel_speed"] = result["speed_travel"]
|
||||
result["time"] = time.strftime("%H:%M:%S") #Some extra settings.
|
||||
|
||||
#Some extra settings.
|
||||
result["time"] = time.strftime("%H:%M:%S")
|
||||
result["date"] = time.strftime("%d-%m-%Y")
|
||||
result["day"] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][int(time.strftime("%w"))]
|
||||
result["initial_extruder_nr"] = CuraApplication.getInstance().getExtruderManager().getInitialExtruderNr()
|
||||
|
@ -455,9 +464,9 @@ class StartSliceJob(Job):
|
|||
bed_temperature_settings = ["material_bed_temperature", "material_bed_temperature_layer_0"]
|
||||
pattern = r"\{(%s)(,\s?\w+)?\}" % "|".join(bed_temperature_settings) # match {setting} as well as {setting, extruder_nr}
|
||||
settings["material_bed_temp_prepend"] = re.search(pattern, start_gcode) == None
|
||||
print_temperature_settings = ["material_print_temperature", "material_print_temperature_layer_0", "default_material_print_temperature", "material_initial_print_temperature", "material_final_print_temperature", "material_standby_temperature"]
|
||||
print_temperature_settings = ["material_print_temperature", "material_print_temperature_layer_0", "default_material_print_temperature", "material_initial_print_temperature", "material_final_print_temperature", "material_standby_temperature", "print_temperature"]
|
||||
pattern = r"\{(%s)(,\s?\w+)?\}" % "|".join(print_temperature_settings) # match {setting} as well as {setting, extruder_nr}
|
||||
settings["material_print_temp_prepend"] = re.search(pattern, start_gcode) == None
|
||||
settings["material_print_temp_prepend"] = re.search(pattern, start_gcode) is None
|
||||
|
||||
# Replace the setting tokens in start and end g-code.
|
||||
# Use values from the first used extruder by default so we get the expected temperatures
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"name": "CuraEngine Backend",
|
||||
"author": "Ultimaker B.V.",
|
||||
"description": "Provides the link to the CuraEngine slicing backend.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"version": "1.0.1",
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
"author": "Ultimaker B.V.",
|
||||
"version": "1.0.1",
|
||||
"description": "Provides support for importing Cura profiles.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
"author": "Ultimaker B.V.",
|
||||
"version": "1.0.1",
|
||||
"description": "Provides support for exporting Cura profiles.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"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.1.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
|
@ -1,7 +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 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
|
||||
<path style="fill:#000E1A;" d="M39,14V8H27v6h-6V8H9v6H7c-1.7,0-3,1.3-3,3v20c0,1.7,1.3,3,3,3h34c1.7,0,3-1.3,3-3V17
|
||||
c0-1.7-1.3-3-3-3H39z M29,10h8v4c-2.1,0-4.9,0-8,0V10z M11,10h8v4c-3,0-5.8,0-8,0V10z M42,38H6V16h36V38z"/>
|
||||
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>
|
Before Width: | Height: | Size: 577 B 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 && dfFilenameTextfield.state !== 'invalid'
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
246
plugins/DigitalLibrary/resources/qml/SelectProjectPage.qml
Normal file
246
plugins/DigitalLibrary/resources/qml/SelectProjectPage.qml
Normal file
|
@ -0,0 +1,246 @@
|
|||
// 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
|
||||
leftPadding: searchIcon.width + UM.Theme.getSize("default_margin").width * 2
|
||||
|
||||
onTextEdited: manager.projectFilter = text //Update the search filter when editing this text field.
|
||||
|
||||
placeholderText: "Search"
|
||||
|
||||
UM.RecolorImage
|
||||
{
|
||||
id: searchIcon
|
||||
|
||||
anchors
|
||||
{
|
||||
verticalCenter: parent.verticalCenter
|
||||
left: parent.left
|
||||
leftMargin: UM.Theme.getSize("default_margin").width
|
||||
}
|
||||
source: UM.Theme.getIcon("search")
|
||||
height: UM.Theme.getSize("small_button_icon").height
|
||||
width: height
|
||||
color: UM.Theme.getColor("text")
|
||||
}
|
||||
}
|
||||
|
||||
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("external_link")
|
||||
visible: createNewProjectButtonVisible && !manager.userAccountCanCreateNewLibraryProject && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
|
||||
tooltip: "Maximum number of projects reached. Please upgrade your subscription to create more projects."
|
||||
|
||||
onClicked: Qt.openUrlExternally("https://ultimaker.com/software/enterprise-software?utm_source=cura&utm_medium=software&utm_campaign=MaxProjLink")
|
||||
}
|
||||
}
|
||||
|
||||
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?utm_source=cura&utm_medium=software&utm_campaign=empty-library")
|
||||
visible: searchBar.text === "" //Show the link to Digital Library when there are no projects in the user's Library.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
17
plugins/DigitalLibrary/src/BackwardsCompatibleMessage.py
Normal file
17
plugins/DigitalLibrary/src/BackwardsCompatibleMessage.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 typing import Optional
|
||||
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from UM.Message import Message
|
||||
from UM.Version import Version
|
||||
|
||||
|
||||
def getBackwardsCompatibleMessage(text: str, title: str, message_type_str: str, lifetime: Optional[int] = 30) -> Message:
|
||||
if CuraApplication.getInstance().getAPIVersion() < Version("7.7.0"):
|
||||
return Message(text=text, title=title, lifetime=lifetime)
|
||||
else:
|
||||
message_type = Message.MessageType.NEUTRAL
|
||||
if ("MessageType." + message_type_str) in [str(item) for item in Message.MessageType]:
|
||||
message_type = Message.MessageType[message_type_str]
|
||||
return Message(text=text, title=title, lifetime=lifetime, message_type=message_type)
|
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)
|
381
plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py
Normal file
381
plugins/DigitalLibrary/src/DFFileExportAndUploadManager.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
|
||||
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 .BackwardsCompatibleMessage import getBackwardsCompatibleMessage
|
||||
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 = getBackwardsCompatibleMessage(
|
||||
text = "Your {} uploaded to '{}'.".format("file was" if len(self._file_upload_job_metadata) <= 1 else "files were", self._library_project_name),
|
||||
title = "Upload successful",
|
||||
lifetime = 30,
|
||||
message_type_str = "POSITIVE"
|
||||
)
|
||||
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"] = getBackwardsCompatibleMessage(
|
||||
text = "Failed to export the file '{}'. The upload process is aborted.".format(filename),
|
||||
title = "Export error",
|
||||
message_type_str = "ERROR",
|
||||
lifetime = 30
|
||||
)
|
||||
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"] = getBackwardsCompatibleMessage(
|
||||
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_3mf, self._library_project_name, human_readable_error),
|
||||
title = "File upload error",
|
||||
message_type_str = "ERROR",
|
||||
lifetime = 30
|
||||
)
|
||||
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"] = getBackwardsCompatibleMessage(
|
||||
title = "File upload error",
|
||||
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_ufp, self._library_project_name, human_readable_error),
|
||||
message_type_str = "ERROR",
|
||||
lifetime = 30
|
||||
)
|
||||
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"] = getBackwardsCompatibleMessage(
|
||||
title = "File upload error",
|
||||
text = "Failed to upload the file '{}' to '{}'. {}".format(self._file_name, self._library_project_name, human_readable_error),
|
||||
message_type_str = "ERROR",
|
||||
lifetime = 30
|
||||
)
|
||||
|
||||
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&utm_source=cura&utm_medium=software&utm_campaign=saved-library-file-message".format(CuraApplication.getInstance().ultimakerDigitalFactoryUrl, self._library_project_id)
|
||||
QDesktopServices.openUrl(QUrl(project_url))
|
||||
message.hide()
|
||||
|
||||
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": getBackwardsCompatibleMessage(
|
||||
text = "'{}' was uploaded to '{}'.".format(filename_3mf, self._library_project_name),
|
||||
title = "Upload successful",
|
||||
message_type_str = "POSITIVE",
|
||||
lifetime = 30
|
||||
),
|
||||
"file_upload_failed_message": getBackwardsCompatibleMessage(
|
||||
text = "Failed to upload the file '{}' to '{}'.".format(filename_3mf, self._library_project_name),
|
||||
title = "File upload error",
|
||||
message_type_str = "ERROR",
|
||||
lifetime = 30
|
||||
)
|
||||
}
|
||||
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": getBackwardsCompatibleMessage(
|
||||
text = "'{}' was uploaded to '{}'.".format(filename_ufp, self._library_project_name),
|
||||
title = "Upload successful",
|
||||
message_type_str = "POSITIVE",
|
||||
lifetime = 30,
|
||||
),
|
||||
"file_upload_failed_message": getBackwardsCompatibleMessage(
|
||||
text = "Failed to upload the file '{}' to '{}'.".format(filename_ufp, self._library_project_name),
|
||||
title = "File upload error",
|
||||
message_type_str = "ERROR",
|
||||
lifetime = 30
|
||||
)
|
||||
}
|
||||
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()
|
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