Merge branch 'master' into feature_extruder_warning_icon

# Conflicts:
#	resources/qml/Menus/ConfigurationMenu/ConfigurationMenu.qml
This commit is contained in:
fieldOfView 2021-07-26 14:12:30 +02:00
commit 9c05d990ea
4471 changed files with 152594 additions and 22537 deletions

View file

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

View file

@ -1,49 +0,0 @@
---
name: Bug report
about: Create a report to help us fix issues.
title: ''
labels: 'Type: Bug'
assignees: ''
---
<!--
Processing an issue will go much faster when this is filled out, and issues which do not use this template WILL BE REMOVED and no fix will be considered!
Before filing, PLEASE check if the issue already exists (either open or closed) by using the search bar on the issues page. If it does, comment there. Even if it's closed, we can reopen it based on your comment.
Also, please note the application version in the title of the issue. For example: "[3.2.1] Cannot connect to 3rd-party printer". Please do NOT write things like "Request:" or "[BUG]" in the title; this is what labels are for.
Thank you for using Cura!
-->
**Application version**
(The version of the application this issue occurs with.)
**Platform**
(Information about the operating system the issue occurs on. Include at least the operating system and maybe GPU.)
**Printer**
(Which printer was selected in Cura?)
**Reproduction steps**
1. (Something you did.)
2. (Something you did next.)
**Screenshot(s)**
(Image showing the problem, perhaps before/after images.)
**Actual results**
(What happens after the above steps have been followed.)
**Expected results**
(What should happen after the above steps have been followed.)
**Project file**
(For slicing bugs, provide a project which clearly shows the bug, by going to File->Save Project. For big files you may need to use WeTransfer or similar file sharing sites. G-code files are not project files!)
**Log file**
(See https://github.com/Ultimaker/Cura#logging-issues to find the log file to upload, or copy a relevant snippet from it.)
**Additional information**
(Extra information relevant to the issue.)

82
.github/ISSUE_TEMPLATE/bugreport.yaml vendored Normal file
View file

@ -0,0 +1,82 @@
name: Bug Report
description: Create a report to help us fix issues.
labels: "Type: Bug"
body:
- type: markdown
attributes:
value: |
**Thank you for using Cura and wanting to report a bug.**
Before filing, please check if the issue already exists (either open or closed) by using the search bar on the issues page. If it does, comment there. Even if it's closed, we can reopen it based on your comment.
Also, please note the application version in the title of the issue "For example (3.2.1) Cannot connect to 3rd-party printer". Please do not write things like **Request** or **BUG** in the title, this is what labels are for.
- type: input
attributes:
label: Application Version
description: The version of Cura this issue occurs with.
placeholder: 4.9.0
validations:
required: true
- type: input
attributes:
label: Platform
description: Information about the operating system the issue occurs on. Include at least the operating system and maybe GPU.
placeholder: Windows 10
validations:
required: true
- type: input
attributes:
label: Printer
description: Which printer was selected in Cura?
placeholder: Ultimaker S5
validations:
required: true
- type: textarea
attributes:
label: Reproduction steps
description: Tell us what you did!
placeholder: |
1. Something you did
2. Something you did next
validations:
required: true
- type: textarea
attributes:
label: Actual results
description: What happens after the above steps have been followed.
validations:
required: true
- type: textarea
attributes:
label: Expected results
description: What should happen after the above steps have been followed.
validations:
required: true
- type: markdown
attributes:
value: |
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** for crashes and similar issues.
You can find your log file here:
Windows: `%APPDATA%\cura\<Cura version>\cura.log` or usually `C:\Users\\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
MacOS: `$USER/Library/Application Support/cura/<Cura version>/cura.log`
Ubuntu/Linus: `$USER/.local/share/cura/<Cura version>/cura.log`
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
- type: checkboxes
attributes:
label: Checklist of files to include
options:
- label: Log file
- label: Project file
- type: textarea
attributes:
label: Additional information & file uploads
description: You can add these files and additional information that is relevant to the issue in the comments below.
validations:
required: true

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

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

View file

@ -1,23 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: 'Type: New Feature'
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
(A clear and concise description of what the problem is. Ex. I'm always frustrated when [...])
**Describe the solution you'd like**
(A clear and concise description of what you want to happen. If possible, describe why you think this is a good solution.)
**Describe alternatives you've considered**
(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.)
**Affected users and/or printers**
(Who do you think will benefit from this? Is everyone going to benefit from these changes? Or specific kinds of users?)
**Additional context**
(Add any other context or screenshots about the feature request here.)

View file

@ -0,0 +1,44 @@
name: Feature Request
description: Suggest an idea for this project.
labels: "Type: New Feature"
body:
- type: markdown
attributes:
value: |
**Thank you for using Cura and wanting to suggest a new feature.**
Before filing, please check if the feature request already exists (either open or closed) by using the search bar on the issues page. If it does, comment there. Even if it's closed, we can reopen it based on your comment.
Please do not write things like **Request** or **BUG** in the title, this is what labels are for.
- type: textarea
attributes:
label: Is your feature request related to a problem?
description: Please describe a clear and concise description of what the problem is.
placeholder: I'm always frustrated when...
validations:
required: true
- type: textarea
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen. If possible, describe why you think this is a good solution.
placeholder: I believe this will solve...
validations:
required: true
- type: textarea
attributes:
label: Describe alternatives you've considered
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
- type: textarea
attributes:
label: Affected users and/or printers
description: Who do you think will benefit from this? Is everyone going to benefit from these changes? Or specific kinds of users?
placeholder: It will affect...
validations:
required: true
- type: textarea
attributes:
label: Additional information & file uploads
description: You can add pictures or files to visualize your feature request in the comments below.

View file

@ -28,7 +28,7 @@ set(CURA_CLOUD_ACCOUNT_API_ROOT "" CACHE STRING "Alternative Cura cloud account
set(CURA_MARKETPLACE_ROOT "" CACHE STRING "Alternative Marketplace location") set(CURA_MARKETPLACE_ROOT "" CACHE STRING "Alternative Marketplace location")
set(CURA_DIGITAL_FACTORY_URL "" CACHE STRING "Alternative Digital Factory location") set(CURA_DIGITAL_FACTORY_URL "" CACHE STRING "Alternative Digital Factory location")
configure_file(${CMAKE_SOURCE_DIR}/cura.desktop.in ${CMAKE_BINARY_DIR}/cura.desktop @ONLY) configure_file(${CMAKE_SOURCE_DIR}/com.ultimaker.cura.desktop.in ${CMAKE_BINARY_DIR}/com.ultimaker.cura.desktop @ONLY)
configure_file(cura/CuraVersion.py.in CuraVersion.py @ONLY) configure_file(cura/CuraVersion.py.in CuraVersion.py @ONLY)
@ -82,11 +82,11 @@ if(NOT APPLE AND NOT WIN32)
install(FILES ${CMAKE_BINARY_DIR}/CuraVersion.py install(FILES ${CMAKE_BINARY_DIR}/CuraVersion.py
DESTINATION lib${LIB_SUFFIX}/python${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/site-packages/cura) DESTINATION lib${LIB_SUFFIX}/python${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/site-packages/cura)
endif() endif()
install(FILES ${CMAKE_BINARY_DIR}/cura.desktop install(FILES ${CMAKE_BINARY_DIR}/com.ultimaker.cura.desktop
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
install(FILES ${CMAKE_SOURCE_DIR}/resources/images/cura-icon.png install(FILES ${CMAKE_SOURCE_DIR}/resources/images/cura-icon.png
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/128x128/apps/) DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/128x128/apps/)
install(FILES cura.appdata.xml install(FILES com.ultimaker.cura.appdata.xml
DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo) DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo)
install(FILES cura.sharedmimeinfo install(FILES cura.sharedmimeinfo
DESTINATION ${CMAKE_INSTALL_DATADIR}/mime/packages/ DESTINATION ${CMAKE_INSTALL_DATADIR}/mime/packages/

View file

@ -10,13 +10,13 @@ For crashes and similar issues, please attach the following information:
* (On Windows) The log as produced by dxdiag (start -> run -> dxdiag -> save output) * (On Windows) The log as produced by dxdiag (start -> run -> dxdiag -> save output)
* The Cura GUI log file, located at * The Cura GUI log file, located at
* `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\\<your username>\AppData\Roaming\cura\<Cura version>\cura.log` * `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
* `$USER/Library/Application Support/cura/<Cura version>/cura.log` (OSX) * `$HOME/Library/Application Support/cura/<Cura version>/cura.log` (OSX)
* `$USER/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux) * `$HOME/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux)
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
For additional support, you could also ask in the #cura channel on FreeNode IRC. For help with development, there is also the #cura-dev channel. For additional support, you could also ask in the [#cura channel](https://web.libera.chat/#cura) on [libera.chat](https://libera.chat/). For help with development, there is also the [#cura-dev channel](https://web.libera.chat/#cura-dev).
Dependencies Dependencies
------------ ------------
@ -26,9 +26,15 @@ Dependencies
* [PySerial](https://github.com/pyserial/pyserial) Only required for USB printing support. * [PySerial](https://github.com/pyserial/pyserial) Only required for USB printing support.
* [python-zeroconf](https://github.com/jstasiak/python-zeroconf) Only required to detect mDNS-enabled printers. * [python-zeroconf](https://github.com/jstasiak/python-zeroconf) Only required to detect mDNS-enabled printers.
For a list of required Python packages, with their recommended version, see `requirements.txt`.
This list is not exhaustive at the moment, please check the links in the next section for more details.
Build scripts Build scripts
------------- -------------
Please checkout [cura-build](https://github.com/Ultimaker/cura-build) for detailed building instructions. Please check out [cura-build](https://github.com/Ultimaker/cura-build) for detailed building instructions.
If you want to build the entire environment from scratch before building Cura as well, [cura-build-environment](https://github.com/Ultimaker/cura-build) might be a starting point before cura-build. (Again, see cura-build for more details.)
Running from Source Running from Source
------------- -------------

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- Copyright 2016 Richard Hughes <richard@hughsie.com> --> <!-- Copyright 2016 Richard Hughes <richard@hughsie.com> -->
<component type="desktop"> <component type="desktop">
<id>cura.desktop</id> <id>com.ultimaker.cura.desktop</id>
<metadata_license>CC0-1.0</metadata_license> <metadata_license>CC0-1.0</metadata_license>
<project_license>LGPL-3.0 and CC-BY-SA-4.0</project_license> <project_license>LGPL-3.0 and CC-BY-SA-4.0</project_license>
<name>Cura</name> <name>Cura</name>
@ -24,7 +24,9 @@
</ul> </ul>
</description> </description>
<screenshots> <screenshots>
<screenshot type="default" width="1280" height="720">http://software.ultimaker.com/Cura.png</screenshot> <screenshot type="default">
<image>https://raw.githubusercontent.com/Ultimaker/Cura/master/screenshot.png</image>
</screenshot>
</screenshots> </screenshots>
<url type="homepage">https://ultimaker.com/en/products/cura-software?utm_source=cura&amp;utm_medium=software&amp;utm_campaign=resources</url> <url type="homepage">https://ultimaker.com/en/products/cura-software?utm_source=cura&amp;utm_medium=software&amp;utm_campaign=resources</url>
<translation type="gettext">Cura</translation> <translation type="gettext">Cura</translation>

View file

@ -40,7 +40,7 @@ class Account(QObject):
""" """
# The interval in which sync services are automatically triggered # The interval in which sync services are automatically triggered
SYNC_INTERVAL = 30.0 # seconds SYNC_INTERVAL = 60.0 # seconds
Q_ENUMS(SyncState) Q_ENUMS(SyncState)
loginStateChanged = pyqtSignal(bool) loginStateChanged = pyqtSignal(bool)
@ -58,6 +58,11 @@ class Account(QObject):
manualSyncEnabledChanged = pyqtSignal(bool) manualSyncEnabledChanged = pyqtSignal(bool)
updatePackagesEnabledChanged = pyqtSignal(bool) updatePackagesEnabledChanged = pyqtSignal(bool)
CLIENT_SCOPES = "account.user.read drive.backup.read drive.backup.write packages.download " \
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write " \
"library.project.read library.project.write cura.printjob.read cura.printjob.write " \
"cura.mesh.read cura.mesh.write"
def __init__(self, application: "CuraApplication", parent = None) -> None: def __init__(self, application: "CuraApplication", parent = None) -> None:
super().__init__(parent) super().__init__(parent)
self._application = application self._application = application
@ -79,9 +84,7 @@ class Account(QObject):
CALLBACK_PORT=self._callback_port, CALLBACK_PORT=self._callback_port,
CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port), CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port),
CLIENT_ID="um----------------------------ultimaker_cura", CLIENT_ID="um----------------------------ultimaker_cura",
CLIENT_SCOPES="account.user.read drive.backup.read drive.backup.write packages.download " CLIENT_SCOPES=self.CLIENT_SCOPES,
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write "
"cura.printjob.read cura.printjob.write cura.mesh.read cura.mesh.write",
AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data", AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data",
AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root), AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root),
AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root) AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root)
@ -106,7 +109,6 @@ class Account(QObject):
self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged) self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged)
self._authorization_service.loadAuthDataFromPreferences() self._authorization_service.loadAuthDataFromPreferences()
@pyqtProperty(int, notify=syncStateChanged) @pyqtProperty(int, notify=syncStateChanged)
def syncState(self): def syncState(self):
return self._sync_state return self._sync_state
@ -175,6 +177,7 @@ class Account(QObject):
if error_message: if error_message:
if self._error_message: if self._error_message:
self._error_message.hide() self._error_message.hide()
Logger.log("w", "Failed to login: %s", error_message)
self._error_message = Message(error_message, title = i18n_catalog.i18nc("@info:title", "Login failed")) self._error_message = Message(error_message, title = i18n_catalog.i18nc("@info:title", "Login failed"))
self._error_message.show() self._error_message.show()
self._logged_in = False self._logged_in = False

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import QTimer from PyQt5.QtCore import QTimer
@ -6,6 +6,8 @@ from typing import Any, TYPE_CHECKING
from UM.Logger import Logger from UM.Logger import Logger
import time
if TYPE_CHECKING: if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
@ -56,8 +58,8 @@ class AutoSave:
def _onTimeout(self) -> None: def _onTimeout(self) -> None:
self._saving = True # To prevent the save process from triggering another autosave. self._saving = True # To prevent the save process from triggering another autosave.
Logger.log("d", "Autosaving preferences, instances and profiles")
save_start_time = time.time()
self._application.saveSettings() self._application.saveSettings()
Logger.log("d", "Autosaving preferences, instances and profiles took %s seconds", time.time() - save_start_time)
self._saving = False self._saving = False

View file

@ -5,14 +5,16 @@ import io
import os import os
import re import re
import shutil import shutil
from copy import deepcopy
from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile
from typing import Dict, Optional, TYPE_CHECKING from typing import Dict, Optional, TYPE_CHECKING, List
from UM import i18nCatalog from UM import i18nCatalog
from UM.Logger import Logger from UM.Logger import Logger
from UM.Message import Message from UM.Message import Message
from UM.Platform import Platform from UM.Platform import Platform
from UM.Resources import Resources from UM.Resources import Resources
from UM.Version import Version
if TYPE_CHECKING: if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
@ -27,6 +29,11 @@ class Backup:
IGNORED_FILES = [r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc"] IGNORED_FILES = [r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc"]
"""These files should be ignored when making a backup.""" """These files should be ignored when making a backup."""
IGNORED_FOLDERS = [] # type: List[str]
SECRETS_SETTINGS = ["general/ultimaker_auth_data"]
"""Secret preferences that need to obfuscated when making a backup of Cura"""
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
"""Re-use translation catalog""" """Re-use translation catalog"""
@ -43,6 +50,9 @@ class Backup:
Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir) Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir)
# obfuscate sensitive secrets
secrets = self._obfuscate()
# Ensure all current settings are saved. # Ensure all current settings are saved.
self._application.saveSettings() self._application.saveSettings()
@ -67,8 +77,9 @@ class Backup:
machine_count = max(len([s for s in files if "machine_instances/" in s]) - 1, 0) # If people delete their profiles but not their preferences, it can still make a backup, and report -1 profiles. Server crashes on this. machine_count = max(len([s for s in files if "machine_instances/" in s]) - 1, 0) # If people delete their profiles but not their preferences, it can still make a backup, and report -1 profiles. Server crashes on this.
material_count = max(len([s for s in files if "materials/" in s]) - 1, 0) material_count = max(len([s for s in files if "materials/" in s]) - 1, 0)
profile_count = max(len([s for s in files if "quality_changes/" in s]) - 1, 0) profile_count = max(len([s for s in files if "quality_changes/" in s]) - 1, 0)
plugin_count = len([s for s in files if "plugin.json" in s]) # We don't store plugins anymore, since if you can make backups, you have an account (and the plugins are
# on the marketplace anyway)
plugin_count = 0
# Store the archive and metadata so the BackupManager can fetch them when needed. # Store the archive and metadata so the BackupManager can fetch them when needed.
self.zip_file = buffer.getvalue() self.zip_file = buffer.getvalue()
self.meta_data = { self.meta_data = {
@ -78,6 +89,8 @@ class Backup:
"profile_count": str(profile_count), "profile_count": str(profile_count),
"plugin_count": str(plugin_count) "plugin_count": str(plugin_count)
} }
# Restore the obfuscated settings
self._illuminate(**secrets)
def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]: def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]:
"""Make a full archive from the given root path with the given name. """Make a full archive from the given root path with the given name.
@ -85,8 +98,7 @@ class Backup:
:param root_path: The root directory to archive recursively. :param root_path: The root directory to archive recursively.
:return: The archive as bytes. :return: The archive as bytes.
""" """
ignore_string = re.compile("|".join(self.IGNORED_FILES + self.IGNORED_FOLDERS))
ignore_string = re.compile("|".join(self.IGNORED_FILES))
try: try:
archive = ZipFile(buffer, "w", ZIP_DEFLATED) archive = ZipFile(buffer, "w", ZIP_DEFLATED)
for root, folders, files in os.walk(root_path): for root, folders, files in os.walk(root_path):
@ -123,8 +135,8 @@ class Backup:
"Tried to restore a Cura backup without having proper data or meta data.")) "Tried to restore a Cura backup without having proper data or meta data."))
return False return False
current_version = self._application.getVersion() current_version = Version(self._application.getVersion())
version_to_restore = self.meta_data.get("cura_release", "master") version_to_restore = Version(self.meta_data.get("cura_release", "master"))
if current_version < version_to_restore: if current_version < version_to_restore:
# Cannot restore version newer than current because settings might have changed. # Cannot restore version newer than current because settings might have changed.
@ -134,8 +146,16 @@ class Backup:
"Tried to restore a Cura backup that is higher than the current version.")) "Tried to restore a Cura backup that is higher than the current version."))
return False return False
# Get the current secrets and store since the back-up doesn't contain those
secrets = self._obfuscate()
version_data_dir = Resources.getDataStoragePath() version_data_dir = Resources.getDataStoragePath()
archive = ZipFile(io.BytesIO(self.zip_file), "r") try:
archive = ZipFile(io.BytesIO(self.zip_file), "r")
except LookupError as e:
Logger.log("d", f"The following error occurred while trying to restore a Cura backup: {str(e)}")
self._showMessage(self.catalog.i18nc("@info:backup_failed", "The following error occurred while trying to restore a Cura backup:") + str(e))
return False
extracted = self._extractArchive(archive, version_data_dir) extracted = self._extractArchive(archive, version_data_dir)
# Under Linux, preferences are stored elsewhere, so we copy the file to there. # Under Linux, preferences are stored elsewhere, so we copy the file to there.
@ -146,6 +166,12 @@ class Backup:
Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file) Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file)
shutil.move(backup_preferences_file, preferences_file) shutil.move(backup_preferences_file, preferences_file)
# Read the preferences from the newly restored configuration (or else the cached Preferences will override the restored ones)
self._application.readPreferencesFromConfiguration()
# Restore the obfuscated settings
self._illuminate(**secrets)
return extracted return extracted
@staticmethod @staticmethod
@ -167,9 +193,36 @@ class Backup:
Logger.log("d", "Removing current data in location: %s", target_path) Logger.log("d", "Removing current data in location: %s", target_path)
Resources.factoryReset() Resources.factoryReset()
Logger.log("d", "Extracting backup to location: %s", target_path) Logger.log("d", "Extracting backup to location: %s", target_path)
try: name_list = archive.namelist()
archive.extractall(target_path) for archive_filename in name_list:
except (PermissionError, EnvironmentError): try:
Logger.logException("e", "Unable to extract the backup due to permission or file system errors.") archive.extract(archive_filename, target_path)
return False except (PermissionError, EnvironmentError):
Logger.logException("e", f"Unable to extract the file {archive_filename} from the backup due to permission or file system errors.")
CuraApplication.getInstance().processEvents()
return True return True
def _obfuscate(self) -> Dict[str, str]:
"""
Obfuscate and remove the secret preferences that are specified in SECRETS_SETTINGS
:return: a dictionary of the removed secrets. Note: the '/' is replaced by '__'
"""
preferences = self._application.getPreferences()
secrets = {}
for secret in self.SECRETS_SETTINGS:
secrets[secret.replace("/", "__")] = deepcopy(preferences.getValue(secret))
preferences.setValue(secret, None)
self._application.savePreferences()
return secrets
def _illuminate(self, **kwargs) -> None:
"""
Restore the obfuscated settings
:param kwargs: a dict of obscured preferences. Note: the '__' of the keys will be replaced by '/'
"""
preferences = self._application.getPreferences()
for key, value in kwargs.items():
preferences.setValue(key.replace("__", "/"), value)
self._application.savePreferences()

View file

@ -4,6 +4,7 @@
from typing import Dict, Optional, Tuple, TYPE_CHECKING from typing import Dict, Optional, Tuple, TYPE_CHECKING
from UM.Logger import Logger from UM.Logger import Logger
from UM.Version import Version
from cura.Backups.Backup import Backup from cura.Backups.Backup import Backup
if TYPE_CHECKING: if TYPE_CHECKING:
@ -52,6 +53,7 @@ class BackupsManager:
backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data) backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data)
restored = backup.restore() restored = backup.restore()
if restored: if restored:
# At this point, Cura will need to restart for the changes to take effect. # At this point, Cura will need to restart for the changes to take effect.
# We don't want to store the data at this point as that would override the just-restored backup. # We don't want to store the data at this point as that would override the just-restored backup.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2020 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import numpy import numpy
@ -281,7 +281,7 @@ class BuildVolume(SceneNode):
continue continue
# If the entire node is below the build plate, still mark it as outside. # If the entire node is below the build plate, still mark it as outside.
node_bounding_box = node.getBoundingBox() node_bounding_box = node.getBoundingBox()
if node_bounding_box and node_bounding_box.top < 0: if node_bounding_box and node_bounding_box.top < 0 and not node.getParent().callDecoration("isGroup"):
node.setOutsideBuildArea(True) node.setOutsideBuildArea(True)
continue continue
# Mark the node as outside build volume if the set extruder is disabled # Mark the node as outside build volume if the set extruder is disabled
@ -344,7 +344,12 @@ class BuildVolume(SceneNode):
# Mark the node as outside build volume if the set extruder is disabled # Mark the node as outside build volume if the set extruder is disabled
extruder_position = node.callDecoration("getActiveExtruderPosition") extruder_position = node.callDecoration("getActiveExtruderPosition")
if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled: try:
if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled:
node.setOutsideBuildArea(True)
return
except IndexError:
# If the extruder doesn't exist, also mark it as unprintable.
node.setOutsideBuildArea(True) node.setOutsideBuildArea(True)
return return
@ -911,6 +916,8 @@ class BuildVolume(SceneNode):
return {} return {}
for area in self._global_container_stack.getProperty("machine_disallowed_areas", "value"): for area in self._global_container_stack.getProperty("machine_disallowed_areas", "value"):
if len(area) == 0:
continue # Numpy doesn't deal well with 0-length arrays, since it can't determine the dimensionality of them.
polygon = Polygon(numpy.array(area, numpy.float32)) polygon = Polygon(numpy.array(area, numpy.float32))
polygon = polygon.getMinkowskiHull(Polygon.approximatedCircle(border_size)) polygon = polygon.getMinkowskiHull(Polygon.approximatedCircle(border_size))
machine_disallowed_polygons.append(polygon) machine_disallowed_polygons.append(polygon)
@ -1063,7 +1070,14 @@ class BuildVolume(SceneNode):
adhesion_type = adhesion_override adhesion_type = adhesion_override
if adhesion_type is None: if adhesion_type is None:
adhesion_type = container_stack.getProperty("adhesion_type", "value") adhesion_type = container_stack.getProperty("adhesion_type", "value")
skirt_brim_line_width = self._global_container_stack.getProperty("skirt_brim_line_width", "value")
# Skirt_brim_line_width is a bit of an odd one out. The primary bit of the skirt/brim is printed
# with the adhesion extruder, but it also prints one extra line by all other extruders. As such, the
# setting does *not* have a limit_to_extruder setting (which means that we can't ask the global extruder what
# the value is.
adhesion_extruder = self._global_container_stack.getProperty("adhesion_extruder_nr", "value")
skirt_brim_line_width = self._global_container_stack.extruderList[int(adhesion_extruder)].getProperty("skirt_brim_line_width", "value")
initial_layer_line_width_factor = self._global_container_stack.getProperty("initial_layer_line_width_factor", "value") initial_layer_line_width_factor = self._global_container_stack.getProperty("initial_layer_line_width_factor", "value")
# Use brim width if brim is enabled OR the prime tower has a brim. # Use brim width if brim is enabled OR the prime tower has a brim.
if adhesion_type == "brim": if adhesion_type == "brim":

View file

@ -67,11 +67,15 @@ class CuraActions(QObject):
current_node = parent_node current_node = parent_node
parent_node = current_node.getParent() parent_node = current_node.getParent()
# This was formerly done with SetTransformOperation but because of # Find out where the bottom of the object is
# unpredictable matrix deconstruction it was possible that mirrors bbox = current_node.getBoundingBox()
# could manifest as rotations. Centering is therefore done by if bbox:
# moving the node to negative whatever its position is: center_y = current_node.getWorldPosition().y - bbox.bottom
center_operation = TranslateOperation(current_node, -current_node._position) else:
center_y = 0
# Move the object so that it's bottom is on to of the buildplate
center_operation = TranslateOperation(current_node, Vector(0, center_y, 0), set_position = True)
operation.addOperation(center_operation) operation.addOperation(center_operation)
operation.push() operation.push()

View file

@ -1,8 +1,9 @@
# Copyright (c) 2020 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import os import os
import sys import sys
import tempfile
import time import time
from typing import cast, TYPE_CHECKING, Optional, Callable, List, Any, Dict from typing import cast, TYPE_CHECKING, Optional, Callable, List, Any, Dict
@ -30,6 +31,7 @@ from UM.Operations.SetTransformOperation import SetTransformOperation
from UM.Platform import Platform from UM.Platform import Platform
from UM.PluginError import PluginNotFoundError from UM.PluginError import PluginNotFoundError
from UM.Preferences import Preferences from UM.Preferences import Preferences
from UM.Qt.Bindings.FileProviderModel import FileProviderModel
from UM.Qt.QtApplication import QtApplication # The class we're inheriting from. from UM.Qt.QtApplication import QtApplication # The class we're inheriting from.
from UM.Resources import Resources from UM.Resources import Resources
from UM.Scene.Camera import Camera from UM.Scene.Camera import Camera
@ -127,7 +129,7 @@ class CuraApplication(QtApplication):
# SettingVersion represents the set of settings available in the machine/extruder definitions. # SettingVersion represents the set of settings available in the machine/extruder definitions.
# You need to make sure that this version number needs to be increased if there is any non-backwards-compatible # You need to make sure that this version number needs to be increased if there is any non-backwards-compatible
# changes of the settings. # changes of the settings.
SettingVersion = 16 SettingVersion = 17
Created = False Created = False
@ -255,6 +257,9 @@ class CuraApplication(QtApplication):
from cura.CuraPackageManager import CuraPackageManager from cura.CuraPackageManager import CuraPackageManager
self._package_manager_class = CuraPackageManager self._package_manager_class = CuraPackageManager
from UM.CentralFileStorage import CentralFileStorage
CentralFileStorage.setIsEnterprise(ApplicationMetadata.IsEnterpriseVersion)
@pyqtProperty(str, constant=True) @pyqtProperty(str, constant=True)
def ultimakerCloudApiRootUrl(self) -> str: def ultimakerCloudApiRootUrl(self) -> str:
return UltimakerCloudConstants.CuraCloudAPIRoot return UltimakerCloudConstants.CuraCloudAPIRoot
@ -456,15 +461,16 @@ class CuraApplication(QtApplication):
self._version_upgrade_manager.setCurrentVersions( self._version_upgrade_manager.setCurrentVersions(
{ {
("quality", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityInstanceContainer, "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"), ("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"), ("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"), ("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"), ("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"), ("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"), ("user", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.UserInstanceContainer, "application/x-uranium-instancecontainer"),
("definition_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.DefinitionChangesContainer, "application/x-uranium-instancecontainer"), ("definition_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.DefinitionChangesContainer, "application/x-uranium-instancecontainer"),
("variant", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.VariantInstanceContainer, "application/x-uranium-instancecontainer"), ("variant", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.VariantInstanceContainer, "application/x-uranium-instancecontainer"),
("setting_visibility", SettingVisibilityPresetsModel.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.SettingVisibilityPreset, "application/x-uranium-preferences"),
} }
) )
@ -601,6 +607,15 @@ class CuraApplication(QtApplication):
@pyqtSlot() @pyqtSlot()
def closeApplication(self) -> None: def closeApplication(self) -> None:
Logger.log("i", "Close application") Logger.log("i", "Close application")
# Workaround: Before closing the window, remove the global stack.
# This is necessary because as the main window gets closed, hundreds of QML elements get updated which often
# request the global stack. However as the Qt-side of the Machine Manager is being dismantled, the conversion of
# the Global Stack to a QObject fails.
# If instead we first take down the global stack, PyQt will just convert `None` to `null` which succeeds, and
# the QML code then gets `null` as the global stack and can deal with that as it deems fit.
self.getMachineManager().setActiveMachine(None)
main_window = self.getMainWindow() main_window = self.getMainWindow()
if main_window is not None: if main_window is not None:
main_window.close() main_window.close()
@ -693,6 +708,8 @@ class CuraApplication(QtApplication):
@pyqtSlot(str) @pyqtSlot(str)
def discardOrKeepProfileChangesClosed(self, option: str) -> None: def discardOrKeepProfileChangesClosed(self, option: str) -> None:
global_stack = self.getGlobalContainerStack() global_stack = self.getGlobalContainerStack()
if global_stack is None:
return
if option == "discard": if option == "discard":
for extruder in global_stack.extruderList: for extruder in global_stack.extruderList:
extruder.userChanges.clear() extruder.userChanges.clear()
@ -756,7 +773,7 @@ class CuraApplication(QtApplication):
self._plugin_registry.addPluginLocation(os.path.join(QtApplication.getInstallPrefix(), "lib" + suffix, "cura")) self._plugin_registry.addPluginLocation(os.path.join(QtApplication.getInstallPrefix(), "lib" + suffix, "cura"))
if not hasattr(sys, "frozen"): if not hasattr(sys, "frozen"):
self._plugin_registry.addPluginLocation(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "plugins")) self._plugin_registry.addPluginLocation(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "plugins"))
self._plugin_registry.loadPlugin("ConsoleLogger") self._plugin_registry.preloaded_plugins.append("ConsoleLogger")
self._plugin_registry.loadPlugins() self._plugin_registry.loadPlugins()
@ -822,6 +839,9 @@ class CuraApplication(QtApplication):
self._add_printer_pages_model_without_cancel.initialize(cancellable = False) self._add_printer_pages_model_without_cancel.initialize(cancellable = False)
self._whats_new_pages_model.initialize() self._whats_new_pages_model.initialize()
# Initialize the FileProviderModel
self._file_provider_model.initialize(self._onFileProviderEnabledChanged)
# Detect in which mode to run and execute that mode # Detect in which mode to run and execute that mode
if self._is_headless: if self._is_headless:
self.runWithoutGUI() self.runWithoutGUI()
@ -889,14 +909,14 @@ class CuraApplication(QtApplication):
diagonal = self.getBuildVolume().getDiagonalSize() diagonal = self.getBuildVolume().getDiagonalSize()
if diagonal < 1: #No printer added yet. Set a default camera distance for normal-sized printers. if diagonal < 1: #No printer added yet. Set a default camera distance for normal-sized printers.
diagonal = 375 diagonal = 375
camera.setPosition(Vector(-80, 250, 700) * diagonal / 375) camera.setPosition(Vector(-80, 180, 700) * diagonal / 375)
camera.lookAt(Vector(0, 0, 0)) camera.lookAt(Vector(0, 0, 0))
controller.getScene().setActiveCamera("3d") controller.getScene().setActiveCamera("3d")
# Initialize camera tool # Initialize camera tool
camera_tool = controller.getTool("CameraTool") camera_tool = controller.getTool("CameraTool")
if camera_tool: if camera_tool:
camera_tool.setOrigin(Vector(0, 100, 0)) camera_tool.setOrigin(Vector(0, 30, 0))
camera_tool.setZoomRange(0.1, 2000) camera_tool.setZoomRange(0.1, 2000)
# Initialize camera animations # Initialize camera animations
@ -1051,6 +1071,13 @@ class CuraApplication(QtApplication):
self._simple_mode_settings_manager = SimpleModeSettingsManager() self._simple_mode_settings_manager = SimpleModeSettingsManager()
return self._simple_mode_settings_manager return self._simple_mode_settings_manager
@pyqtSlot(result = QObject)
def getFileProviderModel(self) -> FileProviderModel:
return self._file_provider_model
def _onFileProviderEnabledChanged(self):
self._file_provider_model.update()
def event(self, event): def event(self, event):
"""Handle Qt events""" """Handle Qt events"""
@ -1256,10 +1283,11 @@ class CuraApplication(QtApplication):
if other_bb is not None: if other_bb is not None:
scene_bounding_box = scene_bounding_box + node.getBoundingBox() scene_bounding_box = scene_bounding_box + node.getBoundingBox()
if print_information: if print_information:
print_information.setPreSliced(is_block_slicing_node) print_information.setPreSliced(is_block_slicing_node)
self.getWorkspaceFileHandler().setEnabled(not is_block_slicing_node)
if not scene_bounding_box: if not scene_bounding_box:
scene_bounding_box = AxisAlignedBox.Null scene_bounding_box = AxisAlignedBox.Null
@ -1466,7 +1494,8 @@ class CuraApplication(QtApplication):
for file_name, nodes in objects_in_filename.items(): for file_name, nodes in objects_in_filename.items():
for node in nodes: for node in nodes:
job = ReadMeshJob(file_name) file_path = os.path.normpath(os.path.dirname(file_name))
job = ReadMeshJob(file_name, add_to_recent_files = file_path != tempfile.gettempdir()) # Don't add temp files to the recent files list
job._node = node # type: ignore job._node = node # type: ignore
job.finished.connect(self._reloadMeshFinished) job.finished.connect(self._reloadMeshFinished)
if has_merged_nodes: if has_merged_nodes:
@ -1502,12 +1531,8 @@ class CuraApplication(QtApplication):
# Compute the center of the objects # Compute the center of the objects
object_centers = [] object_centers = []
# Forget about the translation that the original objects have
zero_translation = Matrix(data=numpy.zeros(3))
for mesh, node in zip(meshes, group_node.getChildren()): for mesh, node in zip(meshes, group_node.getChildren()):
transformation = node.getLocalTransformation() transformed_mesh = mesh.getTransformed(Matrix()) # Forget about the transformations that the original object had.
transformation.setTranslation(zero_translation)
transformed_mesh = mesh.getTransformed(transformation)
center = transformed_mesh.getCenterPosition() center = transformed_mesh.getCenterPosition()
if center is not None: if center is not None:
object_centers.append(center) object_centers.append(center)
@ -1522,7 +1547,7 @@ class CuraApplication(QtApplication):
# Move each node to the same position. # Move each node to the same position.
for mesh, node in zip(meshes, group_node.getChildren()): for mesh, node in zip(meshes, group_node.getChildren()):
node.setTransformation(Matrix()) node.setTransformation(Matrix()) # Removes any changes in position and rotation.
# Align the object around its zero position # Align the object around its zero position
# and also apply the offset to center it inside the group. # and also apply the offset to center it inside the group.
node.setPosition(-mesh.getZeroPosition() - offset) node.setPosition(-mesh.getZeroPosition() - offset)
@ -1720,15 +1745,17 @@ class CuraApplication(QtApplication):
def log(self, msg): def log(self, msg):
Logger.log("d", msg) Logger.log("d", msg)
openProjectFile = pyqtSignal(QUrl, arguments = ["project_file"]) # Emitted when a project file is about to open. openProjectFile = pyqtSignal(QUrl, bool, arguments = ["project_file", "add_to_recent_files"]) # Emitted when a project file is about to open.
@pyqtSlot(QUrl, str, bool)
@pyqtSlot(QUrl, str) @pyqtSlot(QUrl, str)
@pyqtSlot(QUrl) @pyqtSlot(QUrl)
def readLocalFile(self, file: QUrl, project_mode: Optional[str] = None): def readLocalFile(self, file: QUrl, project_mode: Optional[str] = None, add_to_recent_files: bool = True):
"""Open a local file """Open a local file
:param project_mode: How to handle project files. Either None(default): Follow user preference, "open_as_model" :param project_mode: How to handle project files. Either None(default): Follow user preference, "open_as_model"
or "open_as_project". This parameter is only considered if the file is a project file. or "open_as_project". This parameter is only considered if the file is a project file.
:param add_to_recent_files: Whether or not to add the file as an option to the Recent Files list.
""" """
Logger.log("i", "Attempting to read file %s", file.toString()) Logger.log("i", "Attempting to read file %s", file.toString())
if not file.isValid(): if not file.isValid():
@ -1749,12 +1776,12 @@ class CuraApplication(QtApplication):
if is_project_file and project_mode == "open_as_project": if is_project_file and project_mode == "open_as_project":
# open as project immediately without presenting a dialog # open as project immediately without presenting a dialog
workspace_handler = self.getWorkspaceFileHandler() workspace_handler = self.getWorkspaceFileHandler()
workspace_handler.readLocalFile(file) workspace_handler.readLocalFile(file, add_to_recent_files_hint = add_to_recent_files)
return return
if is_project_file and project_mode == "always_ask": if is_project_file and project_mode == "always_ask":
# present a dialog asking to open as project or import models # present a dialog asking to open as project or import models
self.callLater(self.openProjectFile.emit, file) self.callLater(self.openProjectFile.emit, file, add_to_recent_files)
return return
# Either the file is a model file or we want to load only models from project. Continue to load models. # Either the file is a model file or we want to load only models from project. Continue to load models.
@ -1790,7 +1817,7 @@ class CuraApplication(QtApplication):
if extension in self._non_sliceable_extensions: if extension in self._non_sliceable_extensions:
self.deleteAll(only_selectable = False) self.deleteAll(only_selectable = False)
job = ReadMeshJob(f) job = ReadMeshJob(f, add_to_recent_files = add_to_recent_files)
job.finished.connect(self._readMeshFinished) job.finished.connect(self._readMeshFinished)
job.start() job.start()
@ -1841,6 +1868,7 @@ class CuraApplication(QtApplication):
else: else:
node = CuraSceneNode() node = CuraSceneNode()
node.setMeshData(original_node.getMeshData()) node.setMeshData(original_node.getMeshData())
node.source_mime_type = original_node.source_mime_type
# Setting meshdata does not apply scaling. # Setting meshdata does not apply scaling.
if original_node.getScale() != Vector(1.0, 1.0, 1.0): if original_node.getScale() != Vector(1.0, 1.0, 1.0):
@ -1905,6 +1933,11 @@ class CuraApplication(QtApplication):
arrange(nodes_to_arrange, self.getBuildVolume(), fixed_nodes) arrange(nodes_to_arrange, self.getBuildVolume(), fixed_nodes)
except: except:
Logger.logException("e", "Failed to arrange the models") Logger.logException("e", "Failed to arrange the models")
# Ensure that we don't have any weird floaty objects (CURA-7855)
for node in nodes_to_arrange:
node.translate(Vector(0, -node.getBoundingBox().bottom, 0), SceneNode.TransformSpace.World)
self.fileCompleted.emit(file_name) self.fileCompleted.emit(file_name)
def addNonSliceableExtension(self, extension): def addNonSliceableExtension(self, extension):
@ -1921,7 +1954,7 @@ class CuraApplication(QtApplication):
try: try:
result = workspace_reader.preRead(file_path, show_dialog=False) result = workspace_reader.preRead(file_path, show_dialog=False)
return result == WorkspaceReader.PreReadResult.accepted return result == WorkspaceReader.PreReadResult.accepted
except Exception: except:
Logger.logException("e", "Could not check file %s", file_url) Logger.logException("e", "Could not check file %s", file_url)
return False return False

View file

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

View file

@ -88,8 +88,10 @@ class MaterialNode(ContainerNode):
variant = self.variant.variant_name) variant = self.variant.variant_name)
else: else:
qualities_any_material = container_registry.findInstanceContainersMetadata(type = "quality", definition = self.variant.machine.quality_definition) qualities_any_material = container_registry.findInstanceContainersMetadata(type = "quality", definition = self.variant.machine.quality_definition)
for material_metadata in container_registry.findInstanceContainersMetadata(type = "material", material = my_material_type):
qualities.extend((quality for quality in qualities_any_material if quality.get("material") == material_metadata["base_file"])) all_material_base_files = {material_metadata["base_file"] for material_metadata in container_registry.findInstanceContainersMetadata(type = "material", material = my_material_type)}
qualities.extend((quality for quality in qualities_any_material if quality.get("material") in all_material_base_files))
if not qualities: # No quality profiles found. Go by GUID then. if not qualities: # No quality profiles found. Go by GUID then.
my_guid = self.guid my_guid = self.guid

View file

@ -53,6 +53,9 @@ class ExtrudersModel(ListModel):
EnabledRole = Qt.UserRole + 11 EnabledRole = Qt.UserRole + 11
"""Is the extruder enabled?""" """Is the extruder enabled?"""
MaterialTypeRole = Qt.UserRole + 12
"""The type of the material (e.g. PLA, ABS, PETG, etc.)."""
defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"] defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"]
"""List of colours to display if there is no material or the material has no known colour. """ """List of colours to display if there is no material or the material has no known colour. """
@ -75,6 +78,7 @@ class ExtrudersModel(ListModel):
self.addRoleName(self.StackRole, "stack") self.addRoleName(self.StackRole, "stack")
self.addRoleName(self.MaterialBrandRole, "material_brand") self.addRoleName(self.MaterialBrandRole, "material_brand")
self.addRoleName(self.ColorNameRole, "color_name") self.addRoleName(self.ColorNameRole, "color_name")
self.addRoleName(self.MaterialTypeRole, "material_type")
self._update_extruder_timer = QTimer() self._update_extruder_timer = QTimer()
self._update_extruder_timer.setInterval(100) self._update_extruder_timer.setInterval(100)
self._update_extruder_timer.setSingleShot(True) self._update_extruder_timer.setSingleShot(True)
@ -193,7 +197,8 @@ class ExtrudersModel(ListModel):
"variant": extruder.variant.getName() if extruder.variant else "", # e.g. print core "variant": extruder.variant.getName() if extruder.variant else "", # e.g. print core
"stack": extruder, "stack": extruder,
"material_brand": material_brand, "material_brand": material_brand,
"color_name": color_name "color_name": color_name,
"material_type": extruder.material.getMetaDataEntry("material") if extruder.material else "",
} }
items.append(item) items.append(item)
@ -210,7 +215,7 @@ class ExtrudersModel(ListModel):
"id": "", "id": "",
"name": catalog.i18nc("@menuitem", "Not overridden"), "name": catalog.i18nc("@menuitem", "Not overridden"),
"enabled": True, "enabled": True,
"color": "#ffffff", "color": "transparent",
"index": -1, "index": -1,
"definition": "", "definition": "",
"material": "", "material": "",
@ -218,6 +223,7 @@ class ExtrudersModel(ListModel):
"stack": None, "stack": None,
"material_brand": "", "material_brand": "",
"color_name": "", "color_name": "",
"material_type": "",
} }
items.append(item) items.append(item)
if self._items != items: if self._items != items:

View file

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -34,4 +34,4 @@ def fetchLayerHeight(quality_group: "QualityGroup") -> float:
if isinstance(layer_height, SettingFunction): if isinstance(layer_height, SettingFunction):
layer_height = layer_height(global_stack) layer_height = layer_height(global_stack)
return float(layer_height) return round(float(layer_height), 3)

View file

@ -1,10 +1,11 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import copy # To duplicate materials. import copy # To duplicate materials.
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot # To allow the preference page proxy to be used from the actual preferences page. from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl
from typing import Any, Dict, Optional, TYPE_CHECKING from typing import Any, Dict, Optional, TYPE_CHECKING
import uuid # To generate new GUIDs for new materials. import uuid # To generate new GUIDs for new materials.
import zipfile # To export all materials in a .zip archive.
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from UM.Logger import Logger from UM.Logger import Logger
@ -20,11 +21,6 @@ if TYPE_CHECKING:
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
class MaterialManagementModel(QObject): class MaterialManagementModel(QObject):
"""Proxy class to the materials page in the preferences.
This class handles the actions in that page, such as creating new materials, renaming them, etc.
"""
favoritesChanged = pyqtSignal(str) favoritesChanged = pyqtSignal(str)
"""Triggered when a favorite is added or removed. """Triggered when a favorite is added or removed.
@ -79,6 +75,7 @@ class MaterialManagementModel(QObject):
:param material_node: The material to remove. :param material_node: The material to remove.
""" """
Logger.info(f"Removing material {material_node.container_id}")
container_registry = CuraContainerRegistry.getInstance() container_registry = CuraContainerRegistry.getInstance()
materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file) materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file)
@ -194,6 +191,7 @@ class MaterialManagementModel(QObject):
:return: The root material ID of the duplicate material. :return: The root material ID of the duplicate material.
""" """
Logger.info(f"Duplicating material {material_node.base_file} to {new_base_id}")
return self.duplicateMaterialByBaseFile(material_node.base_file, new_base_id, new_metadata) return self.duplicateMaterialByBaseFile(material_node.base_file, new_base_id, new_metadata)
@pyqtSlot(result = str) @pyqtSlot(result = str)
@ -262,3 +260,40 @@ class MaterialManagementModel(QObject):
self.favoritesChanged.emit(material_base_file) self.favoritesChanged.emit(material_base_file)
except ValueError: # Material was not in the favorites list. except ValueError: # Material was not in the favorites list.
Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file)) Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file))
@pyqtSlot(result = QUrl)
def getPreferredExportAllPath(self) -> QUrl:
"""
Get the preferred path to export materials to.
If there is a removable drive, that should be the preferred path. Otherwise it should be the most recent local
file path.
:return: The preferred path to export all materials to.
"""
cura_application = cura.CuraApplication.CuraApplication.getInstance()
device_manager = cura_application.getOutputDeviceManager()
devices = device_manager.getOutputDevices()
for device in devices:
if device.__class__.__name__ == "RemovableDriveOutputDevice":
return QUrl.fromLocalFile(device.getId())
else: # No removable drives? Use local path.
return cura_application.getDefaultPath("dialog_material_path")
@pyqtSlot(QUrl)
def exportAll(self, file_path: QUrl) -> None:
"""
Export all materials to a certain file path.
:param file_path: The path to export the materials to.
"""
registry = CuraContainerRegistry.getInstance()
archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED)
for metadata in registry.findInstanceContainersMetadata(type = "material"):
if metadata["base_file"] != metadata["id"]: # Only process base files.
continue
if metadata["id"] == "empty_material": # Don't export the empty material.
continue
material = registry.findContainers(id = metadata["id"])[0]
suffix = registry.getMimeTypeForContainer(type(material)).preferredSuffix
filename = metadata["id"] + "." + suffix
archive.writestr(filename, material.serialize())

View file

@ -99,7 +99,7 @@ class QualitySettingsModel(ListModel):
if self._selected_position == self.GLOBAL_STACK_POSITION: if self._selected_position == self.GLOBAL_STACK_POSITION:
quality_node = quality_group.node_for_global quality_node = quality_group.node_for_global
else: else:
quality_node = quality_group.nodes_for_extruders.get(str(self._selected_position)) quality_node = quality_group.nodes_for_extruders.get(self._selected_position)
settings_keys = quality_group.getAllKeys() settings_keys = quality_group.getAllKeys()
quality_containers = [] quality_containers = []
if quality_node is not None and quality_node.container is not None: if quality_node is not None and quality_node.container is not None:
@ -114,10 +114,13 @@ class QualitySettingsModel(ListModel):
global_container = None if len(global_containers) == 0 else global_containers[0] global_container = None if len(global_containers) == 0 else global_containers[0]
extruders_containers = {pos: container_registry.findContainers(id = quality_changes_group.metadata_per_extruder[pos]["id"]) for pos in quality_changes_group.metadata_per_extruder} extruders_containers = {pos: container_registry.findContainers(id = quality_changes_group.metadata_per_extruder[pos]["id"]) for pos in quality_changes_group.metadata_per_extruder}
extruders_container = {pos: None if not containers else containers[0] for pos, containers in extruders_containers.items()} extruders_container = {pos: None if not containers else containers[0] for pos, containers in extruders_containers.items()}
quality_changes_metadata = None
if self._selected_position == self.GLOBAL_STACK_POSITION and global_container: if self._selected_position == self.GLOBAL_STACK_POSITION and global_container:
quality_changes_metadata = global_container.getMetaData() quality_changes_metadata = global_container.getMetaData()
else: else:
quality_changes_metadata = extruders_container.get(str(self._selected_position)) extruder = extruders_container.get(self._selected_position)
if extruder:
quality_changes_metadata = extruder.getMetaData()
if quality_changes_metadata is not None: # It can be None if number of extruders are changed during runtime. if quality_changes_metadata is not None: # It can be None if number of extruders are changed during runtime.
container = container_registry.findContainers(id = quality_changes_metadata["id"]) container = container_registry.findContainers(id = quality_changes_metadata["id"])
if container: if container:

View file

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

View file

@ -1,12 +1,12 @@
# Copyright (c) 2020 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime from datetime import datetime
import json import json
import random import random
from hashlib import sha512 from hashlib import sha512
from base64 import b64encode from base64 import b64encode
from typing import Optional, Any, Dict, Tuple from typing import Optional
import requests import requests
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
@ -58,7 +58,7 @@ class AuthorizationHelpers:
:return: An AuthenticationResponse object. :return: An AuthenticationResponse object.
""" """
Logger.log("d", "Refreshing the access token.") Logger.log("d", "Refreshing the access token for [%s]", self._settings.OAUTH_SERVER_URL)
data = { data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
"redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "",
@ -69,7 +69,9 @@ class AuthorizationHelpers:
try: try:
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server") return AuthenticationResponse(success = False, err_message = "Unable to connect to remote server")
except OSError as e:
return AuthenticationResponse(success = False, err_message = "Operating system is unable to set up a secure connection: {err}".format(err = str(e)))
@staticmethod @staticmethod
def parseTokenResponse(token_response: requests.models.Response) -> "AuthenticationResponse": def parseTokenResponse(token_response: requests.models.Response) -> "AuthenticationResponse":
@ -108,10 +110,12 @@ class AuthorizationHelpers:
""" """
try: try:
token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = { check_token_url = "{}/check-token".format(self._settings.OAUTH_SERVER_URL)
Logger.log("d", "Checking the access token for [%s]", check_token_url)
token_request = requests.get(check_token_url, headers = {
"Authorization": "Bearer {}".format(access_token) "Authorization": "Bearer {}".format(access_token)
}) })
except requests.exceptions.ConnectionError: except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
# Connection was suddenly dropped. Nothing we can do about that. # Connection was suddenly dropped. Nothing we can do about that.
Logger.logException("w", "Something failed while attempting to parse the JWT token") Logger.logException("w", "Something failed while attempting to parse the JWT token")
return None return None

View file

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import json import json
@ -113,8 +113,10 @@ class AuthorizationService:
# The token could not be refreshed using the refresh token. We should login again. # The token could not be refreshed using the refresh token. We should login again.
return None return None
# Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been deleted # Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been deleted
# from the server already. # from the server already. Do not store the auth_data if we could not get new auth_data (eg due to a
self._storeAuthData(self._auth_data) # 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) return self._auth_helpers.parseJWT(self._auth_data.access_token)
def getAccessToken(self) -> Optional[str]: def getAccessToken(self) -> Optional[str]:
@ -241,13 +243,13 @@ class AuthorizationService:
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"))
self._unable_to_get_data_message.show() self._unable_to_get_data_message.show()
except ValueError: except (ValueError, TypeError):
Logger.logException("w", "Could not load auth data from preferences") Logger.logException("w", "Could not load auth data from preferences")
def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None: def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None:
"""Store authentication data in preferences.""" """Store authentication data in preferences."""
Logger.log("d", "Attempting to store the auth data") Logger.log("d", "Attempting to store the auth data for [%s]", self._settings.OAUTH_SERVER_URL)
if self._preferences is None: if self._preferences is None:
Logger.log("e", "Unable to save authentication data, since no preference has been set!") Logger.log("e", "Unable to save authentication data, since no preference has been set!")
return return
@ -255,10 +257,9 @@ class AuthorizationService:
self._auth_data = auth_data self._auth_data = auth_data
if auth_data: if auth_data:
self._user_profile = self.getUserProfile() self._user_profile = self.getUserProfile()
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data))) self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(auth_data.dump()))
else: else:
self._user_profile = None self._user_profile = None
self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY) self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)
self.accessTokenChanged.emit() self.accessTokenChanged.emit()

View file

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

View file

@ -54,6 +54,7 @@ class LocalAuthorizationServer:
if self._web_server: if self._web_server:
# If the server is already running (because of a previously aborted auth flow), we don't have to start it. # If the server is already running (because of a previously aborted auth flow), we don't have to start it.
# We still inject the new verification code though. # We still inject the new verification code though.
Logger.log("d", "Auth web server was already running. Updating the verification code")
self._web_server.setVerificationCode(verification_code) self._web_server.setVerificationCode(verification_code)
return return
@ -85,6 +86,7 @@ class LocalAuthorizationServer:
except OSError: except OSError:
# OS error can happen if the socket was already closed. We really don't care about that case. # OS error can happen if the socket was already closed. We really don't care about that case.
pass pass
Logger.log("d", "Local oauth2 web server was shut down")
self._web_server = None self._web_server = None
self._web_server_thread = None self._web_server_thread = None
@ -96,12 +98,13 @@ class LocalAuthorizationServer:
:return: None :return: None
""" """
Logger.log("d", "Local web server for authorization has started")
if self._web_server: if self._web_server:
if sys.platform == "win32": if sys.platform == "win32":
try: try:
self._web_server.serve_forever() self._web_server.serve_forever()
except OSError as e: except OSError:
Logger.warning(str(e)) Logger.logException("w", "An exception happened while serving the auth server")
else: else:
# Leave the default behavior in non-windows platforms # Leave the default behavior in non-windows platforms
self._web_server.serve_forever() self._web_server.serve_forever()

View file

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

View file

@ -119,21 +119,23 @@ class CuraSceneNode(SceneNode):
self._aabb = None self._aabb = None
if self._mesh_data: if self._mesh_data:
self._aabb = self._mesh_data.getExtents(self.getWorldTransformation(copy = False)) self._aabb = self._mesh_data.getExtents(self.getWorldTransformation(copy = False))
else: # If there is no mesh_data, use a bounding box that encompasses the local (0,0,0)
position = self.getWorldPosition()
self._aabb = AxisAlignedBox(minimum = position, maximum = position)
for child in self.getAllChildren(): for child in self.getAllChildren():
if child.callDecoration("isNonPrintingMesh"): if child.callDecoration("isNonPrintingMesh"):
# Non-printing-meshes inside a group should not affect push apart or drop to build plate # Non-printing-meshes inside a group should not affect push apart or drop to build plate
continue continue
if not child.getMeshData(): child_bb = child.getBoundingBox()
# Nodes without mesh data should not affect bounding boxes of their parents. if child_bb is None or child_bb.minimum == child_bb.maximum:
# Child had a degenerate bounding box, such as an empty group. Don't count it along.
continue continue
if self._aabb is None: if self._aabb is None:
self._aabb = child.getBoundingBox() self._aabb = child_bb
else: else:
self._aabb = self._aabb + child.getBoundingBox() self._aabb = self._aabb + child_bb
if self._aabb is None: # No children that should be included? Just use your own position then, but it's an invalid AABB.
position = self.getWorldPosition()
self._aabb = AxisAlignedBox(minimum = position, maximum = position)
def __deepcopy__(self, memo: Dict[int, object]) -> "CuraSceneNode": def __deepcopy__(self, memo: Dict[int, object]) -> "CuraSceneNode":
"""Taken from SceneNode, but replaced SceneNode with CuraSceneNode""" """Taken from SceneNode, but replaced SceneNode with CuraSceneNode"""
@ -142,6 +144,7 @@ class CuraSceneNode(SceneNode):
copy.setTransformation(self.getLocalTransformation(copy= False)) copy.setTransformation(self.getLocalTransformation(copy= False))
copy.setMeshData(self._mesh_data) copy.setMeshData(self._mesh_data)
copy.setVisible(cast(bool, deepcopy(self._visible, memo))) copy.setVisible(cast(bool, deepcopy(self._visible, memo)))
copy.source_mime_type = cast(str, deepcopy(self.source_mime_type, memo))
copy._selectable = cast(bool, deepcopy(self._selectable, memo)) copy._selectable = cast(bool, deepcopy(self._selectable, memo))
copy._name = cast(str, deepcopy(self._name, memo)) copy._name = cast(str, deepcopy(self._name, memo))
for decorator in self._decorators: for decorator in self._decorators:

View file

@ -1,4 +1,4 @@
# Copyright (c) 2020 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import os import os
@ -221,6 +221,7 @@ class ContainerManager(QObject):
except OSError: except OSError:
return {"status": "error", "message": "Unable to write to this location.", "path": file_url} return {"status": "error", "message": "Unable to write to this location.", "path": file_url}
Logger.info("Successfully exported container to {path}".format(path = file_url))
return {"status": "success", "message": "Successfully exported container", "path": file_url} return {"status": "success", "message": "Successfully exported container", "path": file_url}
@pyqtSlot(QUrl, result = "QVariantMap") @pyqtSlot(QUrl, result = "QVariantMap")
@ -240,6 +241,7 @@ class ContainerManager(QObject):
file_url = file_url_or_string.toLocalFile() file_url = file_url_or_string.toLocalFile()
else: else:
file_url = file_url_or_string file_url = file_url_or_string
Logger.info(f"Importing material from {file_url}")
if not file_url or not os.path.exists(file_url): if not file_url or not os.path.exists(file_url):
return {"status": "error", "message": "Invalid path"} return {"status": "error", "message": "Invalid path"}

View file

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import os import os
@ -381,9 +381,10 @@ class CuraContainerRegistry(ContainerRegistry):
if profile_count > 1: if profile_count > 1:
continue continue
# Only one profile found, this should not ever be the case, so that profile needs to be removed! # Only one profile found, this should not ever be the case, so that profile needs to be removed!
Logger.log("d", "Found an invalid quality_changes profile with the name %s. Going to remove that now", profile_name)
invalid_quality_changes = ContainerRegistry.getInstance().findContainersMetadata(name=profile_name) invalid_quality_changes = ContainerRegistry.getInstance().findContainersMetadata(name=profile_name)
self.removeContainer(invalid_quality_changes[0]["id"]) if invalid_quality_changes:
Logger.log("d", "Found an invalid quality_changes profile with the name %s. Going to remove that now", profile_name)
self.removeContainer(invalid_quality_changes[0]["id"])
@override(ContainerRegistry) @override(ContainerRegistry)
def _isMetadataValid(self, metadata: Optional[Dict[str, Any]]) -> bool: def _isMetadataValid(self, metadata: Optional[Dict[str, Any]]) -> bool:
@ -400,7 +401,9 @@ class CuraContainerRegistry(ContainerRegistry):
try: try:
if int(metadata["setting_version"]) != cura.CuraApplication.CuraApplication.SettingVersion: if int(metadata["setting_version"]) != cura.CuraApplication.CuraApplication.SettingVersion:
return False return False
except ValueError: #Not parsable as int. except ValueError: # Not parsable as int.
return False
except TypeError: # Expecting string input here, not e.g. list or anything.
return False return False
return True return True

View file

@ -86,6 +86,14 @@ class GlobalStack(CuraContainerStack):
def supportsNetworkConnection(self): def supportsNetworkConnection(self):
return self.getMetaDataEntry("supports_network_connection", False) return self.getMetaDataEntry("supports_network_connection", False)
@pyqtProperty(bool, constant = True)
def supportsMaterialExport(self):
"""
Whether the printer supports Cura's export format of material profiles.
:return: ``True`` if it supports it, or ``False`` if not.
"""
return self.getMetaDataEntry("supports_material_export", False)
@classmethod @classmethod
def getLoadingPriority(cls) -> int: def getLoadingPriority(cls) -> int:
return 2 return 2

View file

@ -1,4 +1,4 @@
# Copyright (c) 2016 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtSlot, pyqtProperty, QObject, pyqtSignal, QRegExp from PyQt5.QtCore import pyqtSlot, pyqtProperty, QObject, pyqtSignal, QRegExp
@ -23,7 +23,7 @@ class MachineNameValidator(QObject):
#Compute the validation regex for printer names. This is limited by the maximum file name length. #Compute the validation regex for printer names. This is limited by the maximum file name length.
try: try:
filename_max_length = os.statvfs(Resources.getDataStoragePath()).f_namemax filename_max_length = os.statvfs(Resources.getDataStoragePath()).f_namemax
except AttributeError: #Doesn't support statvfs. Probably because it's not a Unix system. except (AttributeError, EnvironmentError): # Doesn't support statvfs. Probably because it's not a Unix system. Or perhaps there is no permission or it doesn't exist.
filename_max_length = 255 #Assume it's Windows on NTFS. filename_max_length = 255 #Assume it's Windows on NTFS.
machine_name_max_length = filename_max_length - len("_current_settings.") - len(ContainerRegistry.getMimeTypeForContainer(InstanceContainer).preferredSuffix) machine_name_max_length = filename_max_length - len("_current_settings.") - len(ContainerRegistry.getMimeTypeForContainer(InstanceContainer).preferredSuffix)
# Characters that urllib.parse.quote_plus escapes count for 12! So now # Characters that urllib.parse.quote_plus escapes count for 12! So now

View file

@ -1,4 +1,4 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import numpy import numpy
@ -25,8 +25,8 @@ class Snapshot:
pixels = numpy.frombuffer(pixel_array, dtype=numpy.uint8).reshape([height, width, 4]) pixels = numpy.frombuffer(pixel_array, dtype=numpy.uint8).reshape([height, width, 4])
# Find indices of non zero pixels # Find indices of non zero pixels
nonzero_pixels = numpy.nonzero(pixels) nonzero_pixels = numpy.nonzero(pixels)
min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1) min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1) # type: ignore
max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1) max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1) # type: ignore
return min_x, max_x, min_y, max_y return min_x, max_x, min_y, max_y
@ -42,8 +42,8 @@ class Snapshot:
""" """
scene = Application.getInstance().getController().getScene() scene = Application.getInstance().getController().getScene()
active_camera = scene.getActiveCamera() active_camera = scene.getActiveCamera() or scene.findCamera("3d")
render_width, render_height = active_camera.getWindowSize() render_width, render_height = (width, height) if active_camera is None else active_camera.getWindowSize()
render_width = int(render_width) render_width = int(render_width)
render_height = int(render_height) render_height = int(render_height)
preview_pass = PreviewPass(render_width, render_height) preview_pass = PreviewPass(render_width, render_height)

View file

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

View file

@ -4,7 +4,6 @@
import json import json
import math import math
import os import os
import unicodedata
from typing import Dict, List, Optional, TYPE_CHECKING from typing import Dict, List, Optional, TYPE_CHECKING
from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot, QTimer from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot, QTimer
@ -301,10 +300,11 @@ class PrintInformation(QObject):
if self._base_name == "": if self._base_name == "":
self._job_name = self.UNTITLED_JOB_NAME self._job_name = self.UNTITLED_JOB_NAME
self._is_user_specified_job_name = False self._is_user_specified_job_name = False
self._application.getController().getScene().clearMetaData()
self.jobNameChanged.emit() self.jobNameChanged.emit()
return return
base_name = self._stripAccents(self._base_name) base_name = self._base_name
self._defineAbbreviatedMachineName() self._defineAbbreviatedMachineName()
# Only update the job name when it's not user-specified. # Only update the job name when it's not user-specified.
@ -400,11 +400,6 @@ class PrintInformation(QObject):
self._abbr_machine = self._application.getMachineManager().getAbbreviatedMachineName(active_machine_type_name) self._abbr_machine = self._application.getMachineManager().getAbbreviatedMachineName(active_machine_type_name)
def _stripAccents(self, to_strip: str) -> str:
"""Utility method that strips accents from characters (eg: â -> a)"""
return ''.join(char for char in unicodedata.normalize('NFD', to_strip) if unicodedata.category(char) != 'Mn')
@pyqtSlot(result = "QVariantMap") @pyqtSlot(result = "QVariantMap")
def getFeaturePrintTimes(self) -> Dict[str, Duration]: def getFeaturePrintTimes(self) -> Dict[str, Duration]:
result = {} result = {}

View file

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import collections import collections
@ -6,9 +6,11 @@ from typing import Optional, Dict, List, cast
from PyQt5.QtCore import QObject, pyqtSlot from PyQt5.QtCore import QObject, pyqtSlot
from UM.i18n import i18nCatalog
from UM.Resources import Resources from UM.Resources import Resources
from UM.Version import Version from UM.Version import Version
catalog = i18nCatalog("cura")
# #
# This manager provides means to load texts to QML. # This manager provides means to load texts to QML.
@ -30,30 +32,33 @@ class TextManager(QObject):
# Load change log texts and organize them with a dict # Load change log texts and organize them with a dict
try: try:
file_path = Resources.getPath(Resources.Texts, "change_log.txt") file_path = Resources.getPath(Resources.Texts, "change_log.txt")
except FileNotFoundError: except FileNotFoundError as e:
# I have no idea how / when this happens, but we're getting crash reports about it. # I have no idea how / when this happens, but we're getting crash reports about it.
return "" return catalog.i18nc("@text:window", "The release notes could not be opened.") + "<br>" + str(e)
change_logs_dict = {} # type: Dict[Version, Dict[str, List[str]]] change_logs_dict = {} # type: Dict[Version, Dict[str, List[str]]]
with open(file_path, "r", encoding = "utf-8") as f: try:
open_version = None # type: Optional[Version] with open(file_path, "r", encoding = "utf-8") as f:
open_header = "" # Initialise to an empty header in case there is no "*" in the first line of the changelog open_version = None # type: Optional[Version]
for line in f: open_header = "" # Initialise to an empty header in case there is no "*" in the first line of the changelog
line = line.replace("\n", "") for line in f:
if "[" in line and "]" in line: line = line.replace("\n", "")
line = line.replace("[", "") if "[" in line and "]" in line:
line = line.replace("]", "") line = line.replace("[", "")
open_version = Version(line) line = line.replace("]", "")
if open_version > Version([14, 99, 99]): # Bit of a hack: We released the 15.x.x versions before 2.x open_version = Version(line)
open_version = Version([0, open_version.getMinor(), open_version.getRevision(), open_version.getPostfixVersion()]) if open_version > Version([14, 99, 99]): # Bit of a hack: We released the 15.x.x versions before 2.x
open_header = "" open_version = Version([0, open_version.getMinor(), open_version.getRevision(), open_version.getPostfixVersion()])
change_logs_dict[open_version] = collections.OrderedDict() open_header = ""
elif line.startswith("*"): change_logs_dict[open_version] = collections.OrderedDict()
open_header = line.replace("*", "") elif line.startswith("*"):
change_logs_dict[cast(Version, open_version)][open_header] = [] open_header = line.replace("*", "")
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] = []
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 # Format changelog text
content = "" content = ""

View file

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

View file

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

View file

@ -16,14 +16,6 @@ import argparse
import faulthandler import faulthandler
import os import os
# Workaround for a race condition on certain systems where there
# is a race condition between Arcus and PyQt. Importing Arcus
# first seems to prevent Sip from going into a state where it
# tries to create PyQt objects on a non-main thread.
import Arcus # @UnusedImport
import Savitar # @UnusedImport
import pynest2d # @UnusedImport
from PyQt5.QtNetwork import QSslConfiguration, QSslSocket from PyQt5.QtNetwork import QSslConfiguration, QSslSocket
from UM.Platform import Platform from UM.Platform import Platform
@ -226,6 +218,12 @@ if Platform.isLinux() and getattr(sys, "frozen", False):
import trimesh.exchange.load import trimesh.exchange.load
os.environ["LD_LIBRARY_PATH"] = old_env os.environ["LD_LIBRARY_PATH"] = old_env
# WORKAROUND: Cura#5488
# When using the KDE qqc2-desktop-style, the UI layout is completely broken, and
# even worse, it crashes when switching to the "Preview" pane.
if Platform.isLinux():
os.environ["QT_QUICK_CONTROLS_STYLE"] = "default"
if ApplicationMetadata.CuraDebugMode: if ApplicationMetadata.CuraDebugMode:
ssl_conf = QSslConfiguration.defaultConfiguration() ssl_conf = QSslConfiguration.defaultConfiguration()
ssl_conf.setPeerVerifyMode(QSslSocket.VerifyNone) ssl_conf.setPeerVerifyMode(QSslSocket.VerifyNone)

View file

@ -7,7 +7,7 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
PROJECT_DIR="$( cd "${SCRIPT_DIR}/.." && pwd )" PROJECT_DIR="$( cd "${SCRIPT_DIR}/.." && pwd )"
# Make sure that environment variables are set properly # Make sure that environment variables are set properly
source /opt/rh/devtoolset-7/enable source /opt/rh/devtoolset-8/enable
export PATH="${CURA_BUILD_ENV_PATH}/bin:${PATH}" export PATH="${CURA_BUILD_ENV_PATH}/bin:${PATH}"
export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}" export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}"

View file

@ -27,7 +27,7 @@ Note: The profiler front-end itself is quite "heavy" (ok, not optimised). It run
What the Profiler Sees What the Profiler Sees
---------------------- ----------------------
The profiler doesn't capture every function call in Cura. It hooks into a number of important systems which give a good picture of activity without too much run time overhead. The most important system is Uranium's signal mechanism and PyQt5 slots. Functions which are called via the signal mechanism are recorded and thier names appear in the results. PyQt5 slots appear in the results with the prefix `[SLOT]`. The profiler doesn't capture every function call in Cura. It hooks into a number of important systems which give a good picture of activity without too much run time overhead. The most important system is Uranium's signal mechanism and PyQt5 slots. Functions which are called via the signal mechanism are recorded and their names appear in the results. PyQt5 slots appear in the results with the prefix `[SLOT]`.
Note that not all slots are captured. Only those slots which belong to classes which use the `pyqtSlot` decorator from the `UM.FlameProfiler` module. Note that not all slots are captured. Only those slots which belong to classes which use the `pyqtSlot` decorator from the `UM.FlameProfiler` module.

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

113
docs/scene/operations.md Normal file
View file

@ -0,0 +1,113 @@
# Operations and the OperationStack
Cura supports an operation stack. The `OperationStack` class maintains a history of the operations performed in Cura, which allows for undo and redo actions. Every operation registers itself in the stack. The OperationStuck supports the following functions:
* `push(operation)`: Pushes an operation in the stack and applies the operation. This function is called when an operation pushes itself in the stack.
* `undo()`: Reverses the actions performed by the last operation and reduces the current index of the stack.
* `redo()`: Applies the actions performed by the next operation in the stack and increments the current index of the stack.
* `getOperations()`: Returns a list of all the operations that are currently inside the OperationStack
* `canUndo()`: Indicates whether the index of the operation stack has reached the bottom of the stack, which means that there are no more operations to be undone.
* `canRedo()`: Indicates whether the index of the operation stack has reached the top of the stack, which means that there are no more operations to be redone.
**Note 1:** When consecutive operations are performed very quickly after each other, they are merged together at the top of the stack. This action ensures that these minor operation will be undone with one Undo keystroke (e.g. when moving the object around and you press and release the left mouse button really fast, it is considered as one move operation).
**Note 2:** When an operation is pushed in the middle of the stack, all operations above it are removed from the stack. This ensures that there won't be any "history branches" created.
### Operations
Every action that happens in the scene and affects one or multiple models is associated with a subclass of the `Operation` class and is it added to the `OperationStack`. The subclassed operations that can be found in Cura (excluding the ones from downloadable plugins) are the following:
* [GroupedOperation](#groupedoperation)
* [AddSceneNodeOperation](#addscenenodeoperation)
* [RemoveSceneNodeOperation](#removescenenodeoperation)
* [SetParentOperation](#setparentoperation)
* [SetTransformOperation](#settransformoperation)
* [SetObjectExtruderOperation](#setobjectextruderoperation)
* [GravityOperation](#gravityoperation)
* [PlatformPhysicsOperation](#platformphysicsoperation)
* [TranslateOperation](#translateoperation)
* [ScaleOperation](#scaleoperation)
* [RotateOperation](#rotateoperation)
* [MirrorOperation](#mirroroperation)
* [LayFlatOperation](#layflatoperation)
* [SetBuildPlateNumberOperation]()
### GroupedOperation
The `GroupedOperation` is an operation that groups several other operations together. The intent of this operation is to hide an underlying chain of operations from the user if they correspond to only one interaction with the user, such as an operation applied to multiple scene nodes or a re-arrangement of multiple items in the scene.
Once a `GroupedOperation` is pushed into the stack, it applies all of its children operations in one go. Similarly, when it is undone, it reverses all its children operations at once.
### AddSceneNodeOperation
The `AddSceneNodeOperation` is added to the stack whenever a mesh is loaded inside the `Scene`, either by a `FileReader` or by inserting a [Support Blocker](tools.md#supporteraser-tool) in an object.
### RemoveSceneNodeOperation
The `RemoveSceneNodeOperation` is added to the stack whenever a mesh is removed from the Scene by the user or when the user requests to clear the build plate (_Ctrl+D_).
### SetParentOperation
The `SetParentOperation` changes the parent of a node. It is primarily used when grouping (the group node is set as the nodes' parent) and ungrouping (the group's children's parent is set to the group's parent before the group node is deleted), or when a SupportEraser node is added to the scene (to set the selected object as the Eraser's parent).
### SetTransformOperation
The `SetTransformOperation` translates, rotates, and scales a node all at once. This operation accepts a transformation matrix, an orientation matrix, and a scale matrix, and it is used by the _"Reset All Model Positions"_ and _"Reset All Model Transformations"_ options in the right-click (context) menu.
### SetObjectExtruderOperation
This operation is used to set the extruder with which a certain object should be printed with. It adds a [SettingOverrideDecorator](scene.md#settingoverridedecorator) to the object (if it doesn't have any) and then sets the extruder number via the decoration function `node.callDecoration("setActiveExtruder", extruder_id)`.
### GravityOperation
The `GravityOperation` moves a scene node down to 0 on the y-axis. It is currently used by the _"Lay flat"_ and _"Select face to align to the build plate"_ actions of the `RotationTool` to ensure that the object will end up touching the build plate after the corresponding rotation operations have be done.
### PlatformPhysicsOperation
The `PlatformPhysicsOperation` is generated by the `PlatformPhysics` class and it is associated with the preferences _"Ensure models are kept apart"_ and _"Automatically drop models to the build plate"_. If any of these preferences is set to true, the `PlatformPhysics` class periodically checks to make sure that the two conditions are met and if not, it calculates the move vector for each of the nodes that will satisfy the conditions.
Once the move vectors have been computed, they are applied to the nodes through consecutive `PlatformPhysicsOperations`, whose job is to use the `translate` function on the nodes.
**Note:** When there are multiple nodes, multiple `PlatformPhysicsOperations` may be generated (all models may be moved to ensure they are kept apart). These operations eventually get merged together by the `OperationStack` due to the fact that the individual operations are applied very fast one after the other.
### TranslateOperation
The `TranslateOperation` applies a linear transformation on a node, moving the node in the scene. This operation is primarily linked to the [TranslateTool](tools.md#translatetool) but it is also used in other places around Cura, such as arranging objects on the build plate (Ctrl+R) and centering an object to the build plate (via the right-click context menu's _"Center Selected Model"_ option).
When an object is moved using the move tool handles, multiple translate operations are generated to make sure that the object is rendered properly while it is moved. These translate operations are merged together once the user releases the tool handle.
**Note:** Some functionalities may move (translate) nodes without generating a TranslateOperation (such as when a model with is imported from a 3mf into a certain position). This ensures that the moving of the object cannot be accidentally undone by the user.
### ScaleOperation
The `ScaleOperation` scales the selected scene node uniformly or non-uniformly. This operation is primarily generated by the [ScaleTool](tools.md#scaletool).
When an object is scaled using the scale tool handles, multiple scale operations are generated to make sure that the object is rendered properly while it is being resized. These scale operations are merged together once the user releases the tool handle.
**Note:** When the _"Scale extremely small models"_ or the _"Scale large models"_ preferences are enabled the model is scaled when it is inserted into the build plate but it **DOES NOT** generate a `ScaleOperation`. This ensures that Cura doesn't register the scaling as an action that can be undone and the user doesn't accidentally end up with a very big or very small model.
### RotateOperation
The `RotateOperation` rotates the selected scene node(s) according to a given rotation quaternion and, optionally, around a given point. This operation is primarily generated by the [RotationTool](tools.md#rotatetool). It is also used by the arrange algorithm, which may rotate some models to fit them in the build plate.
When an object is rotated using the rotate tool handles, multiple rotate operations are generated to make sure that the object is rendered properly while it is being rotated. These operations are merged together once the user releases the tool handle.
### MirrorOperation
The `MirrorOperation` mirrors the selected object. It is primarily associated with the [MirrorTool](tools.md#mirrortool) and allows for mirroring the object in a certain direction, using the `MirrorToolHandles`.
The `MirrorOperation` accepts a transformation matrix that should only define values on the diagonal of the matrix, and only the values 1 or -1. It allows for mirroring around the center of the object or around the axis origin. The latter isn't used that often.
### LayFlatOperation
The `LayFlatOperation` computes some orientation to hopefully lay the object flat on the build plate. It is generated by the `layFlat()` function of the [RotateTool](tools.md#rotatetool). Contrary to the other operations, the `LayFlatOperation` is computed in a separate thread through the `LayFlatJob` since it can be quite computationally expensive.
### SetBuildPlateNumberOperation
The `SetBuildPlateNumberOperation` is linked to a legacy feature which allowed the user to have multiple build plates open in Cura at the same time. With this operation it was possible to transfer a node to another build plate through the node's [BuildPlateDecorator](scene.md#buildplatedecorator) by calling the decoration `node.callDecoration("setBuildPlateNumber", new_build_plate_nr)`.
**Note:** Changing the active build plate is a disabled feature in Cura and it is intended to be completely removed (internal ticket: CURA-4975), along with the `SetBuildPlateNumberOperation`.

View file

@ -8,19 +8,209 @@ Cura's scene graph is a mere tree data structure. This tree contains all scene n
The main idea behind the scene tree is that each scene node has a transformation applied to it. The scene nodes can be nested beneath other scene nodes. The transformation of the parents is then also applied to the children. This way you can have scene nodes grouped together and transform the group as a whole. Since the transformations are all linear, this ensures that the elements of this group stay in the same relative position and orientation. It will look as if the whole group is a single object. This idea is very common for games where objects are often composed of multiple 3D models but need to move together as a whole. For Cura it is used to group objects together and to transform the collision area correctly. The main idea behind the scene tree is that each scene node has a transformation applied to it. The scene nodes can be nested beneath other scene nodes. The transformation of the parents is then also applied to the children. This way you can have scene nodes grouped together and transform the group as a whole. Since the transformations are all linear, this ensures that the elements of this group stay in the same relative position and orientation. It will look as if the whole group is a single object. This idea is very common for games where objects are often composed of multiple 3D models but need to move together as a whole. For Cura it is used to group objects together and to transform the collision area correctly.
Class Diagram
----
The following class diagram depicts the classes that interact with the Scene
![alt text](images/components_interacting_with_scene.jpg)
The scene lives in the Controller of the Application, and it is primarily interacting with SceneNode objects, which are the components of the Scene Graph.
A Typical Scene A Typical Scene
---- ----
Cura's scene has a few nodes that are always present, and a few nodes that are repeated for every object that the user loads onto their build plate. To give an idea of how a scene normally looks, this is an overview of a typical scene tree for Cura. Cura's scene has a few nodes that are always present, and a few nodes that are repeated for every object that the user loads onto their build plate. The root of the scene graph is a SceneNode that lives inside the Scene and contains all the other children SceneNodes of the scene. Typically, inside the root you can find the SceneNodes that are always loaded (the Cameras, the [BuildVolume](build_volume.md), and the Platform), the objects that are loaded on the platform, and finally a ConvexHullNode for each object and each group of objects in the Scene.
* Root Let's take the following example Scene:
* Camera
* [Build volume](build_volume.md) ![scene_example.png](images/scene_example.jpg)
* Platform
* Object 1 The scene graph in this case is the following:
* Group 1
* Object 2
* Object 3 ![scene_example_scene_graph.png](images/scene_example_scene_graph.jpg)
* Object 1 convex hull node
* Object 2 convex hull node **Note 1:** The Platform is actually a child of the BuildVolume.
* Object 3 convex hull node
* Group 1 convex hull node **Note 2:** The ConvexHullNodes are not actually named after the object they decorate. Their names are used in the image to convey how the ConvexHullNodes are related to the objects in the scene.
**Note 3:** The CuraSceneNode that holds the layer data (inside the BuildVolume) is created and destroyed according to the availability of sliced layer data provided by the CuraEngine. See the [LayerDataDecorator](#layerdatadecorator) for more information.
Accessing SceneNodes in the Scene
----
SceneNodes can be accessed using a `BreadthFirstIterator` or a `DepthFirstIterator`. Each iterator traverses the scene graph and returns a Python iterator, which yields all the SceneNodes and their children.
``` python
for node in BreadthFirstIterator(scene.getRoot()):
# do stuff with the node
```
Example result when iterating the above scene graph:
```python
[i for i in BreadthFirstIterator(CuraApplication.getInstance().getController().getScene().getRoot()]
```
* 00 = {SceneNode} <SceneNode object: 'Root'>
* 01 = {BuildVolume} <BuildVolume object '0x2e35dbce108'>
* 02 = {Camera} <Camera object: '3d'>
* 03 = {CuraSceneNode} <CuraSceneNode object: 'Torus.stl'>
* 04 = {CuraSceneNode} <CuraSceneNode object: 'Group #1'>
* 05 = {Camera} <Camera object: 'snapshot'>
* 06 = {CuraSceneNode} <CuraSceneNode object: 'Star.stl'>
* 07 = {ConvexHullNode} <ConvexHullNode object: '0x2e3000def08'>
* 08 = {ConvexHullNode} <ConvexHullNode object: '0x2e36861bd88'>
* 09 = {ConvexHullNode} <ConvexHullNode object: '0x2e3000bd4c8'>
* 10 = {ConvexHullNode} <ConvexHullNode object: '0x2e35fbb62c8'>
* 11 = {ConvexHullNode} <ConvexHullNode object: '0x2e3000a0648'>
* 12 = {ConvexHullNode} <ConvexHullNode object: '0x2e30019d0c8'>
* 13 = {ConvexHullNode} <ConvexHullNode object: '0x2e3001a2dc8'>
* 14 = {Platform} <Platform object '0x2e35a001948'>
* 15 = {CuraSceneNode} <CuraSceneNode object: 'Group #2'>
* 16 = {CuraSceneNode} <CuraSceneNode object: 'Sphere.stl'>
* 17 = {CuraSceneNode} <CuraSceneNode object: 'Cylinder.stl'>
* 18 = {CuraSceneNode} <CuraSceneNode object: 'Cube.stl'>
SceneNodeDecorators
----
SceneNodeDecorators are decorators that can be added to the nodes of the scene to provide them with additional functions.
Cura provides the following classes derived from the SceneNodeDecorator class:
1. [GroupDecorator](#groupdecorator)
2. [ConvexHullDecorator](#convexhulldecorator)
3. [SettingOverrideDecorator](#settingoverridedecorator)
4. [SliceableObjectDecorator](#sliceableobjectdecorator)
5. [LayerDataDecorator](#layerdatadecorator)
6. [ZOffsetDecorator](#zoffsetdecorator)
7. [BlockSlicingDecorator](#blockslicingdecorator)
8. [GCodeListDecorator](#gcodelistdecorator)
9. [BuildPlateDecorator](#buildplatedecorator)
GroupDecorator
----
Whenever objects on the build plate are grouped together, a new node is added in the scene as the parent of the grouped objects. Group nodes can be identified when traversing the SceneGraph by running the following:
```python
node.callDecoration("isGroup") == True
```
Group nodes decorated by GroupDecorators are added in the scene either by reading project files which contain grouped objects, or when the user selects multiple objects and groups them together (Ctrl + G).
Group nodes that are left with only one child are removed from the scene, making their only child a child of the group's parent. In addition, group nodes without any remaining children are removed from the scene.
ConvexHullDecorator
----
As seen in the scene graph of the scene example, each CuraSceneNode that represents an object on the build plate is linked to a ConvexHullNode which is rendered as the object's shadow on the build plate. The ConvexHullDecorator is the link between these two nodes.
In essence, the CuraSceneNode has a ConvexHullDecorator which points to the ConvexHullNode of the object. The data of the object's convex hull can be accessed via
```python
convex_hull_polygon = object_node.callDecoration("getConvexHull")
```
The ConvexHullDecorator also provides convex hulls that include the head, the fans, and the adhesion of the object. These are primarily used and rendered when One-at-a-time mode is activated.
For more information on the functions added to the node by this decorator, visit the [ConvexHullDecorator.py](https://github.com/Ultimaker/Cura/blob/master/cura/Scene/ConvexHullDecorator.py).
SettingOverrideDecorator
----
SettingOverrideDecorators are primarily used for modifier meshes such as support meshes, cutting meshes, infill meshes, and anti-overhang meshes. When a user converts an object to a modifier mesh, the object's node is decorated by a SettingOverrideDecorator. This decorator adds a PerObjectContainerStack to the CuraSceneNode, which allows the user to modify the settings of the specific model.
For more information on the functions added to the node by this decorator, visit the [SettingOverrideDecorator.py](https://github.com/Ultimaker/Cura/blob/master/cura/Settings/SettingOverrideDecorator.py).
SliceableObjectDecorator
----
This is a convenience decorator that allows us to easily identify the nodes which can be sliced. All **individual** objects (meshes) added to the build plate receive this decorator, apart from the nodes loaded from GCode files (.gcode, .g, .gz, .ufp).
The SceneNodes that do not receive this decorator are:
- Cameras
- BuildVolume
- Platform
- ConvexHullNodes
- CuraSceneNodes that serve as group nodes (these have a GroupDecorator instead)
- The CuraSceneNode that serves as the layer data node
- ToolHandles
- NozzleNode
- Nodes that contain GCode data. See the [BlockSlicingDecorator](#blockslicingdecorator) for more information on that.
This decorator provides the following function to the node:
```python
node.callDecoration("isSliceable")
```
LayerDataDecorator
----
Once the Slicing has completed and the CuraEngine has returned the slicing data, Cura creates a CuraSceneNode inside the BuildVolume which is decorated by a LayerDataDecorator. This decorator holds the layer data of the scene.
![Layer Data Scene Node](images/layer_data_scene_node.jpg)
The layer data can be accessed through the function given to the aforementioned CuraSceneNode by the LayerDataDecorator:
```python
node.callDecoration("getLayerData")
```
This CuraSceneNode is created once Cura has completed processing the Layer data (after the user clicks on the Preview tab after slicing). The CuraSceneNode then is destroyed once any action that changes the Scene occurs (e.g. if the user moves/rotates/scales an object or changes a setting value), indicating that the layer data is no longer available. When that happens, the "Slice" button becomes available again.
ZOffsetDecorator
----
The ZOffsetDecorator is added to an object in the scene when that object is moved below the build plate. It is primarily used when the "Automatically drop models to the build plate" preference is enabled, in order to make sure that the GravityOperation, which drops the mode on the build plate, is not applied when the object is moved under the build plate.
The amount the object is moved under the build plate can be retrieved by calling the "getZOffset" decoration on the node:
```python
z_offset = node.callDecoration("getZOffset")
```
The ZOffsetDecorator is removed from the node when the node is move above the build plate.
BlockSlicingDecorator
----
The BlockSlicingDecorator is the opposite of the SliceableObjectDecorator. It is added on objects loaded on the scene which should not be sliced. This decorator is primarily added on objects loaded from ".gcode", ".ufp", ".g", and ".gz" files. Such an object already contains all the slice information and therefore should not allow Cura to slice it.
If an object with a BlockSlicingDecorator appears in the scene, the backend (CuraEngine) and the print setup (changing print settings) become disabled, considering that G-code files cannot be modified.
The BlockSlicingDecorator adds the following decoration function to the node:
```python
node.callDecoration("isBlockSlicing")
```
GCodeListDecorator
----
The GCodeListDecorator is also added only when a file containing GCode is loaded in the scene. It's purpose is to hold a list of all the GCode data of the loaded object.
The GCode list data is stored in the scene's gcode_dict attribute which then is used in other places in the Cura code, e.g. to provide the GCode to the GCodeWriter or to the PostProcessingPlugin.
The GCode data becomes available by calling the "getGCodeList" decoration of the node:
```python
gcode_list = node.callDecoration("getGCodeList")
```
The CuraSceneNode with the GCodeListDecorator is destroyed when another object or project file is loaded in the Scene.
BuildPlateDecorator
----
The BuildPlateDecorator is added to all the CuraSceneNodes. This decorator is linked to a legacy feature which allowed the user to have multiple build plates open in Cura at the same time. With this decorator it was possible to determine which nodes are present on each build plate, and therefore, which objects should be visible in the currently active build plate. It indicates the number of the build plate this scene node belongs to, which currently is always the build plate -1.
This decorator provides a function to the node that returns the number of the build plate it belongs to:
```python
node.callDecoration("getBuildPlateNumber")
```
**Note:** Changing the active build plate is a disabled feature in Cura and it is intended to be completely removed (internal ticket: CURA-4975).

86
docs/scene/tools.md Normal file
View file

@ -0,0 +1,86 @@
# Tools
Tools are plugin objects which are used to manipulate or interact with the scene and the objects (node) in the scene.
![Class diagram of tools in the scene](images/tools_tool-handles_class_diagram.jpg)
Tools live inside the Controller of the Application and may be associated with ToolHandles. Some of them interact with the scene as a whole (such as the Camera), while others interact with the objects (nodes) in the Scene (selection tool, rotate tool, scale tool etc.). The tools that are available in Cura (excluding the ones provided by downloadable plugins) are the following:
* [CameraTool](#cameratool)
* [SelectionTool](#selectiontool)
* [TranslateTool](#translatetool)
* [ScaleTool](#scaletool)
* [RotateTool](#rotatetool)
* [MirrorTool](#mirrortool)
* [PerObjectSettingsTool](#perobjectsettingstool)
* [SupportEraserTool](#supporteraser)
*****
### CameraTool
The CameraTool is the tool that allows the user to manipulate the Camera. It provides the functions of moving, zooming, and rotating the Camera. This tool does not contain a handle.
### SelectionTool
This tool allows the user to select objects and groups of objects in the scene. The selected objects gain a blue outline and become available in the code through the Selection class.
![Selection Tool](images/selection_tool.jpg)
This tool does not contain a handle.
### TranslateTool
This tool allows the user to move the object around the build plate. The TranslateTool is activated once the user presses the Move icon in the tool sidebar or hits the shortcut (T) while an object is selected.
![Translate Tool](images/translate_tool.jpg)
The TranslateTool contains the TranslateToolHandle, which draws the arrow handles on the selected object(s). The TranslateTool generates TranslateOperations whenever the object is moved around the build plate.
### ScaleTool
This tool allows the user to scale the selected object(s). The ScaleTool is activated once the user presses the Scale icon in the tool sidebar or hits the shortcut (S) while an object is selected.
![Scale Tool](images/scale_tool.jpg)
The ScaleTool contains the ScaleToolHandle, which draws the box handles on the selected object(s). The ScaleTool generates ScaleOperations whenever the object is scaled.
### RotateTool
This tool allows the user to rotate the selected object(s). The RotateTool is activated once the user presses the Rotate icon in the tool sidebar or hits the shortcut (R) while an object is selected.
![Rotate Tool](images/rotate_tool.jpg)
The RotateTool contains the RotateToolHandle, which draws the donuts (tori) and arrow handles on the selected object(s). The RotateTool generates RotateOperations whenever the object is rotated or if a face is selected to be laid flat on the build plate. It also contains the `layFlat()` action, which generates the [LayFlatOperation](operations.md#layflatoperation).
### MirrorTool
This tool allows the user to mirror the selected object(s) in the required direction. The MirrorTool is activated once the user presses the Mirror icon in the tool sidebar or hits the shortcut (M) while an object is selected.
![Mirror Tool](images/mirror_tool.jpg)
The MirrorTool contains the MirrorToolHandle, which draws pyramid handles on the selected object(s). The MirrorTool generates MirrorOperations whenever the object is mirrored against an axis.
### PerObjectSettingsTool
This tool allows the user to change the mesh type of the object into one of the following:
* Normal Model
* Print as support
* Modify settings for overlaps
- Infill mesh only
- Cutting mesh
* Don't support overlaps
![Per object settings tool](images/per_objectsettings_tool.jpg)
Contrary to other tools, this tool doesn't have any handles and it does not generate any operations. This means that once an object's type is changed it cannot be undone/redone using the OperationStack. This tool adds a [SettingOverrideDecorator](scene.md#settingoverridedecorator) on the object's node instead, which allows the user to change certain settings only for this mesh.
### SupportEraser tool
This tool allows the user to add support blockers on the selected model. The SupportEraserTool is activated once the user pressed the Support Blocker icon in the tool sidebar or hits the shortcut (E) while an object is selected. With this tool active, the user can add support blockers (cubes) on the object by clicking on various places on the selected mesh.
![Support Eraser Tool](images/support_blocker_tool.jpg)
The SupportEraser uses a GroupOperation to add a new CuraSceneNode (the eraser) in the scene and set the selected model as the parent of the eraser. This means that the addition of Erasers in the scene can be undone/redone. The SupportEraser does not have any tool handles.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import os.path import os.path
@ -51,6 +51,10 @@ class ThreeMFReader(MeshReader):
self._root = None self._root = None
self._base_name = "" self._base_name = ""
self._unit = None self._unit = None
self._empty_project = False
def emptyFileHintSet(self) -> bool:
return self._empty_project
def _createMatrixFromTransformationString(self, transformation: str) -> Matrix: def _createMatrixFromTransformationString(self, transformation: str) -> Matrix:
if transformation == "": if transformation == "":
@ -159,9 +163,9 @@ class ThreeMFReader(MeshReader):
um_node.callDecoration("getStack").getTop().setDefinition(definition_id) um_node.callDecoration("getStack").getTop().setDefinition(definition_id)
setting_container = um_node.callDecoration("getStack").getTop() setting_container = um_node.callDecoration("getStack").getTop()
known_setting_keys = um_node.callDecoration("getStack").getAllKeys()
for key in settings: for key in settings:
setting_value = settings[key] setting_value = settings[key].value
# Extruder_nr is a special case. # Extruder_nr is a special case.
if key == "extruder_nr": if key == "extruder_nr":
@ -171,7 +175,10 @@ class ThreeMFReader(MeshReader):
else: else:
Logger.log("w", "Unable to find extruder in position %s", setting_value) Logger.log("w", "Unable to find extruder in position %s", setting_value)
continue continue
setting_container.setProperty(key, "value", setting_value) if key in known_setting_keys:
setting_container.setProperty(key, "value", setting_value)
else:
um_node.metadata[key] = settings[key]
if len(um_node.getChildren()) > 0 and um_node.getMeshData() is None: if len(um_node.getChildren()) > 0 and um_node.getMeshData() is None:
if len(um_node.getAllChildren()) == 1: if len(um_node.getAllChildren()) == 1:
@ -193,6 +200,7 @@ class ThreeMFReader(MeshReader):
return um_node return um_node
def _read(self, file_name: str) -> Union[SceneNode, List[SceneNode]]: def _read(self, file_name: str) -> Union[SceneNode, List[SceneNode]]:
self._empty_project = False
result = [] result = []
# The base object of 3mf is a zipped archive. # The base object of 3mf is a zipped archive.
try: try:
@ -201,6 +209,10 @@ class ThreeMFReader(MeshReader):
parser = Savitar.ThreeMFParser() parser = Savitar.ThreeMFParser()
scene_3mf = parser.parse(archive.open("3D/3dmodel.model").read()) scene_3mf = parser.parse(archive.open("3D/3dmodel.model").read())
self._unit = scene_3mf.getUnit() self._unit = scene_3mf.getUnit()
for key, value in scene_3mf.getMetadata().items():
CuraApplication.getInstance().getController().getScene().setMetaDataEntry(key, value)
for node in scene_3mf.getSceneNodes(): for node in scene_3mf.getSceneNodes():
um_node = self._convertSavitarNodeToUMNode(node, file_name) um_node = self._convertSavitarNodeToUMNode(node, file_name)
if um_node is None: if um_node is None:
@ -257,6 +269,9 @@ class ThreeMFReader(MeshReader):
result.append(um_node) result.append(um_node)
if len(result) == 0:
self._empty_project = True
except Exception: except Exception:
Logger.logException("e", "An exception occurred in 3mf reader.") Logger.logException("e", "An exception occurred in 3mf reader.")
return [] return []

View file

@ -1,4 +1,4 @@
# Copyright (c) 2020 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from configparser import ConfigParser from configparser import ConfigParser
@ -412,7 +412,12 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
quality_container_id = parser["containers"][str(_ContainerIndexes.Quality)] quality_container_id = parser["containers"][str(_ContainerIndexes.Quality)]
quality_type = "empty_quality" quality_type = "empty_quality"
if quality_container_id not in ("empty", "empty_quality"): if quality_container_id not in ("empty", "empty_quality"):
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 # Get machine info
serialized = archive.open(global_stack_file).read().decode("utf-8") serialized = archive.open(global_stack_file).read().decode("utf-8")
@ -636,6 +641,13 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
message.show() message.show()
self.setWorkspaceName("") self.setWorkspaceName("")
return [], {} 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"))
message.show()
self.setWorkspaceName("")
return [], {}
cura_file_names = [name for name in archive.namelist() if name.startswith("Cura/")] cura_file_names = [name for name in archive.namelist() if name.startswith("Cura/")]
@ -1150,7 +1162,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
return return
machine_manager.setQualityChangesGroup(quality_changes_group, no_dialog = True) machine_manager.setQualityChangesGroup(quality_changes_group, no_dialog = True)
else: else:
self._quality_type_to_apply = self._quality_type_to_apply.lower() self._quality_type_to_apply = self._quality_type_to_apply.lower() if self._quality_type_to_apply else None
quality_group_dict = container_tree.getCurrentQualityGroups() quality_group_dict = container_tree.getCurrentQualityGroups()
if self._quality_type_to_apply in quality_group_dict: if self._quality_type_to_apply in quality_group_dict:
quality_group = quality_group_dict[self._quality_type_to_apply] quality_group = quality_group_dict[self._quality_type_to_apply]

View file

@ -1,4 +1,4 @@
// Copyright (c) 2016 Ultimaker B.V. // Copyright (c) 2020 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher. // Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10 import QtQuick 2.10
@ -7,6 +7,7 @@ import QtQuick.Layouts 1.3
import QtQuick.Window 2.2 import QtQuick.Window 2.2
import UM 1.1 as UM import UM 1.1 as UM
import Cura 1.1 as Cura
UM.Dialog UM.Dialog
{ {
@ -110,13 +111,14 @@ UM.Dialog
height: visible ? comboboxHeight : 0 height: visible ? comboboxHeight : 0
visible: base.visible && machineResolveComboBox.model.count > 1 visible: base.visible && machineResolveComboBox.model.count > 1
text: catalog.i18nc("@info:tooltip", "How should the conflict in the machine be resolved?") text: catalog.i18nc("@info:tooltip", "How should the conflict in the machine be resolved?")
ComboBox Cura.ComboBox
{ {
id: machineResolveComboBox id: machineResolveComboBox
model: manager.updatableMachinesModel model: manager.updatableMachinesModel
visible: machineResolveStrategyTooltip.visible visible: machineResolveStrategyTooltip.visible
textRole: "displayName" textRole: "displayName"
width: parent.width width: parent.width
height: UM.Theme.getSize("button").height
onCurrentIndexChanged: onCurrentIndexChanged:
{ {
if (model.getItem(currentIndex).id == "new" if (model.getItem(currentIndex).id == "new"
@ -217,12 +219,13 @@ UM.Dialog
height: visible ? comboboxHeight : 0 height: visible ? comboboxHeight : 0
visible: manager.qualityChangesConflict visible: manager.qualityChangesConflict
text: catalog.i18nc("@info:tooltip", "How should the conflict in the profile be resolved?") text: catalog.i18nc("@info:tooltip", "How should the conflict in the profile be resolved?")
ComboBox Cura.ComboBox
{ {
model: resolveStrategiesModel model: resolveStrategiesModel
textRole: "label" textRole: "label"
id: qualityChangesResolveComboBox id: qualityChangesResolveComboBox
width: parent.width width: parent.width
height: UM.Theme.getSize("button").height
onActivated: onActivated:
{ {
manager.setResolveStrategy("quality_changes", resolveStrategiesModel.get(index).key) manager.setResolveStrategy("quality_changes", resolveStrategiesModel.get(index).key)
@ -323,12 +326,13 @@ UM.Dialog
height: visible ? comboboxHeight : 0 height: visible ? comboboxHeight : 0
visible: manager.materialConflict visible: manager.materialConflict
text: catalog.i18nc("@info:tooltip", "How should the conflict in the material be resolved?") text: catalog.i18nc("@info:tooltip", "How should the conflict in the material be resolved?")
ComboBox Cura.ComboBox
{ {
model: resolveStrategiesModel model: resolveStrategiesModel
textRole: "label" textRole: "label"
id: materialResolveComboBox id: materialResolveComboBox
width: parent.width width: parent.width
height: UM.Theme.getSize("button").height
onActivated: onActivated:
{ {
manager.setResolveStrategy("material", resolveStrategiesModel.get(index).key) manager.setResolveStrategy("material", resolveStrategiesModel.get(index).key)
@ -415,7 +419,7 @@ UM.Dialog
width: warningLabel.height width: warningLabel.height
height: width height: width
source: UM.Theme.getIcon("notice") source: UM.Theme.getIcon("Information")
color: palette.text color: palette.text
} }

View file

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

View file

@ -14,6 +14,7 @@ from cura.CuraApplication import CuraApplication
import Savitar import Savitar
import numpy import numpy
import datetime
MYPY = False MYPY = False
try: try:
@ -108,7 +109,11 @@ class ThreeMFWriter(MeshWriter):
# Get values for all changed settings & save them. # Get values for all changed settings & save them.
for key in changed_setting_keys: for key in changed_setting_keys:
savitar_node.setSetting(key, str(stack.getProperty(key, "value"))) savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value")))
# Store the metadata.
for key, value in um_node.metadata.items():
savitar_node.setSetting(key, value)
for child_node in um_node.getChildren(): for child_node in um_node.getChildren():
# only save the nodes on the active build plate # only save the nodes on the active build plate
@ -145,6 +150,22 @@ class ThreeMFWriter(MeshWriter):
model_relation_element = ET.SubElement(relations_element, "Relationship", Target = "/3D/3dmodel.model", Id = "rel0", Type = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel") model_relation_element = ET.SubElement(relations_element, "Relationship", Target = "/3D/3dmodel.model", Id = "rel0", Type = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel")
savitar_scene = Savitar.Scene() savitar_scene = Savitar.Scene()
metadata_to_store = CuraApplication.getInstance().getController().getScene().getMetaData()
for key, value in metadata_to_store.items():
savitar_scene.setMetaDataEntry(key, value)
current_time_string = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if "Application" not in metadata_to_store:
# This might sound a bit strange, but this field should store the original application that created
# the 3mf. So if it was already set, leave it to whatever it was.
savitar_scene.setMetaDataEntry("Application", CuraApplication.getInstance().getApplicationDisplayName())
if "CreationDate" not in metadata_to_store:
savitar_scene.setMetaDataEntry("CreationDate", current_time_string)
savitar_scene.setMetaDataEntry("ModificationDate", current_time_string)
transformation_matrix = Matrix() transformation_matrix = Matrix()
transformation_matrix._data[1, 1] = 0 transformation_matrix._data[1, 1] = 0
transformation_matrix._data[1, 2] = -1 transformation_matrix._data[1, 2] = -1

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,6 @@ import threading
from datetime import datetime from datetime import datetime
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import sentry_sdk
from PyQt5.QtNetwork import QNetworkReply from PyQt5.QtNetwork import QNetworkReply
from UM.Job import Job from UM.Job import Job
@ -99,13 +98,7 @@ class CreateBackupJob(Job):
if HttpRequestManager.safeHttpStatus(reply) == 400: if HttpRequestManager.safeHttpStatus(reply) == 400:
errors = json.loads(replyText)["errors"] errors = json.loads(replyText)["errors"]
if "moreThanMaximum" in [error["code"] for error in errors if error["meta"] and error["meta"]["field_name"] == "backup_size"]: if "moreThanMaximum" in [error["code"] for error in errors if error["meta"] and error["meta"]["field_name"] == "backup_size"]:
if self._backup_zip is None: # will never happen; keep mypy happy
zip_error = "backup is None."
else:
zip_error = "{} exceeds max size.".format(str(len(self._backup_zip)))
sentry_sdk.capture_message("backup failed: {}".format(zip_error), level ="warning")
self.backup_upload_error_message = catalog.i18nc("@error:file_size", "The backup exceeds the maximum file size.") self.backup_upload_error_message = catalog.i18nc("@error:file_size", "The backup exceeds the maximum file size.")
from sentry_sdk import capture_message
self._job_done.set() self._job_done.set()
return return

View file

@ -43,6 +43,10 @@ class DriveApiService:
return return
backup_list_response = HttpRequestManager.readJSON(reply) backup_list_response = HttpRequestManager.readJSON(reply)
if backup_list_response is None:
Logger.error("List of back-ups can't be parsed.")
changed([])
return
if "data" not in backup_list_response: if "data" not in backup_list_response:
Logger.log("w", "Could not get backups from remote, actual response body was: %s", Logger.log("w", "Could not get backups from remote, actual response body was: %s",
str(backup_list_response)) str(backup_list_response))
@ -89,7 +93,7 @@ class DriveApiService:
def _onRestoreFinished(self, job: "RestoreBackupJob") -> None: def _onRestoreFinished(self, job: "RestoreBackupJob") -> None:
if job.restore_backup_error_message != "": if job.restore_backup_error_message != "":
# If the job contains an error message we pass it along so the UI can display it. # If the job contains an error message we pass it along so the UI can display it.
self.restoringStateChanged.emit(is_restoring=False) self.restoringStateChanged.emit(is_restoring = False)
else: else:
self.restoringStateChanged.emit(is_restoring = False, error_message = job.restore_backup_error_message) self.restoringStateChanged.emit(is_restoring = False, error_message = job.restore_backup_error_message)

View file

@ -34,6 +34,9 @@ class DrivePluginExtension(QObject, Extension):
# Signal emitted when preferences changed (like auto-backup). # Signal emitted when preferences changed (like auto-backup).
preferencesChanged = pyqtSignal() preferencesChanged = pyqtSignal()
# Signal emitted when the id of the backup-to-be-restored is changed
backupIdBeingRestoredChanged = pyqtSignal(arguments = ["backup_id_being_restored"])
DATE_FORMAT = "%d/%m/%Y %H:%M:%S" DATE_FORMAT = "%d/%m/%Y %H:%M:%S"
def __init__(self) -> None: def __init__(self) -> None:
@ -45,6 +48,7 @@ class DrivePluginExtension(QObject, Extension):
self._backups = [] # type: List[Dict[str, Any]] self._backups = [] # type: List[Dict[str, Any]]
self._is_restoring_backup = False self._is_restoring_backup = False
self._is_creating_backup = False self._is_creating_backup = False
self._backup_id_being_restored = ""
# Initialize services. # Initialize services.
preferences = CuraApplication.getInstance().getPreferences() preferences = CuraApplication.getInstance().getPreferences()
@ -52,6 +56,7 @@ class DrivePluginExtension(QObject, Extension):
# Attach signals. # Attach signals.
CuraApplication.getInstance().getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged) CuraApplication.getInstance().getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged)
CuraApplication.getInstance().applicationShuttingDown.connect(self._onApplicationShuttingDown)
self._drive_api_service.restoringStateChanged.connect(self._onRestoringStateChanged) self._drive_api_service.restoringStateChanged.connect(self._onRestoringStateChanged)
self._drive_api_service.creatingStateChanged.connect(self._onCreatingStateChanged) self._drive_api_service.creatingStateChanged.connect(self._onCreatingStateChanged)
@ -75,6 +80,10 @@ class DrivePluginExtension(QObject, Extension):
if self._drive_window: if self._drive_window:
self._drive_window.show() self._drive_window.show()
def _onApplicationShuttingDown(self):
if self._drive_window:
self._drive_window.hide()
def _autoBackup(self) -> None: def _autoBackup(self) -> None:
preferences = CuraApplication.getInstance().getPreferences() preferences = CuraApplication.getInstance().getPreferences()
if preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY) and self._isLastBackupTooLongAgo(): if preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY) and self._isLastBackupTooLongAgo():
@ -100,10 +109,11 @@ class DrivePluginExtension(QObject, Extension):
if logged_in: if logged_in:
self.refreshBackups() self.refreshBackups()
def _onRestoringStateChanged(self, is_restoring: bool = False, error_message: str = None) -> None: def _onRestoringStateChanged(self, is_restoring: bool = False, error_message: Optional[str] = None) -> None:
self._is_restoring_backup = is_restoring self._is_restoring_backup = is_restoring
self.restoringStateChanged.emit() self.restoringStateChanged.emit()
if error_message: if error_message:
self.backupIdBeingRestored = ""
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show() Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
def _onCreatingStateChanged(self, is_creating: bool = False, error_message: str = None) -> None: def _onCreatingStateChanged(self, is_creating: bool = False, error_message: str = None) -> None:
@ -152,6 +162,7 @@ class DrivePluginExtension(QObject, Extension):
for backup in self._backups: for backup in self._backups:
if backup.get("backup_id") == backup_id: if backup.get("backup_id") == backup_id:
self._drive_api_service.restoreBackup(backup) self._drive_api_service.restoreBackup(backup)
self.setBackupIdBeingRestored(backup_id)
return return
Logger.log("w", "Unable to find backup with the ID %s", backup_id) Logger.log("w", "Unable to find backup with the ID %s", backup_id)
@ -166,3 +177,12 @@ class DrivePluginExtension(QObject, Extension):
def _backupDeletedCallback(self, success: bool): def _backupDeletedCallback(self, success: bool):
if success: if success:
self.refreshBackups() self.refreshBackups()
def setBackupIdBeingRestored(self, backup_id_being_restored: str) -> None:
if backup_id_being_restored != self._backup_id_being_restored:
self._backup_id_being_restored = backup_id_being_restored
self.backupIdBeingRestoredChanged.emit()
@pyqtProperty(str, fset = setBackupIdBeingRestored, notify = backupIdBeingRestoredChanged)
def backupIdBeingRestored(self) -> str:
return self._backup_id_being_restored

View file

@ -1,3 +1,6 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import base64 import base64
import hashlib import hashlib
import threading import threading
@ -56,14 +59,20 @@ class RestoreBackupJob(Job):
return return
# We store the file in a temporary path fist to ensure integrity. # We store the file in a temporary path fist to ensure integrity.
temporary_backup_file = NamedTemporaryFile(delete = False) try:
with open(temporary_backup_file.name, "wb") as write_backup: temporary_backup_file = NamedTemporaryFile(delete = False)
app = CuraApplication.getInstance() with open(temporary_backup_file.name, "wb") as write_backup:
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE) app = CuraApplication.getInstance()
while bytes_read:
write_backup.write(bytes_read)
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE) bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
app.processEvents() 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", "")): if not self._verifyMd5Hash(temporary_backup_file.name, self._backup.get("md5_hash", "")):
# Don't restore the backup if the MD5 hashes do not match. # Don't restore the backup if the MD5 hashes do not match.

View file

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

View file

@ -38,7 +38,7 @@ Item
height: UM.Theme.getSize("section_icon").height height: UM.Theme.getSize("section_icon").height
color: UM.Theme.getColor("small_button_text") color: UM.Theme.getColor("small_button_text")
hoverColor: UM.Theme.getColor("small_button_text_hover") hoverColor: UM.Theme.getColor("small_button_text_hover")
iconSource: UM.Theme.getIcon("info") iconSource: UM.Theme.getIcon("Information")
onClicked: backupListItem.showDetails = !backupListItem.showDetails onClicked: backupListItem.showDetails = !backupListItem.showDetails
} }
@ -71,6 +71,7 @@ Item
text: catalog.i18nc("@button", "Restore") text: catalog.i18nc("@button", "Restore")
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
onClicked: confirmRestoreDialog.visible = true onClicked: confirmRestoreDialog.visible = true
busy: CuraDrive.backupIdBeingRestored == modelData.backup_id && CuraDrive.isRestoringBackup
} }
UM.SimpleButton UM.SimpleButton
@ -79,7 +80,7 @@ Item
height: UM.Theme.getSize("message_close").height height: UM.Theme.getSize("message_close").height
color: UM.Theme.getColor("small_button_text") color: UM.Theme.getColor("small_button_text")
hoverColor: UM.Theme.getColor("small_button_text_hover") hoverColor: UM.Theme.getColor("small_button_text_hover")
iconSource: UM.Theme.getIcon("cross1") iconSource: UM.Theme.getIcon("Cancel")
onClicked: confirmDeleteDialog.visible = true onClicked: confirmDeleteDialog.visible = true
} }
} }

View file

@ -1,4 +1,4 @@
// Copyright (c) 2018 Ultimaker B.V. // Copyright (c) 2021 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher. // Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7 import QtQuick 2.7
@ -17,7 +17,7 @@ ColumnLayout
// Cura version // Cura version
BackupListItemDetailsRow BackupListItemDetailsRow
{ {
iconSource: UM.Theme.getIcon("application") iconSource: UM.Theme.getIcon("UltimakerCura")
label: catalog.i18nc("@backuplist:label", "Cura Version") label: catalog.i18nc("@backuplist:label", "Cura Version")
value: backupDetailsData.metadata.cura_release value: backupDetailsData.metadata.cura_release
} }
@ -25,7 +25,7 @@ ColumnLayout
// Machine count. // Machine count.
BackupListItemDetailsRow BackupListItemDetailsRow
{ {
iconSource: UM.Theme.getIcon("printer_single") iconSource: UM.Theme.getIcon("Printer")
label: catalog.i18nc("@backuplist:label", "Machines") label: catalog.i18nc("@backuplist:label", "Machines")
value: backupDetailsData.metadata.machine_count value: backupDetailsData.metadata.machine_count
} }
@ -33,7 +33,7 @@ ColumnLayout
// Material count // Material count
BackupListItemDetailsRow BackupListItemDetailsRow
{ {
iconSource: UM.Theme.getIcon("category_material") iconSource: UM.Theme.getIcon("Spool")
label: catalog.i18nc("@backuplist:label", "Materials") label: catalog.i18nc("@backuplist:label", "Materials")
value: backupDetailsData.metadata.material_count value: backupDetailsData.metadata.material_count
} }
@ -41,7 +41,7 @@ ColumnLayout
// Profile count. // Profile count.
BackupListItemDetailsRow BackupListItemDetailsRow
{ {
iconSource: UM.Theme.getIcon("settings") iconSource: UM.Theme.getIcon("Sliders")
label: catalog.i18nc("@backuplist:label", "Profiles") label: catalog.i18nc("@backuplist:label", "Profiles")
value: backupDetailsData.metadata.profile_count value: backupDetailsData.metadata.profile_count
} }
@ -49,7 +49,7 @@ ColumnLayout
// Plugin count. // Plugin count.
BackupListItemDetailsRow BackupListItemDetailsRow
{ {
iconSource: UM.Theme.getIcon("plugin") iconSource: UM.Theme.getIcon("Plugin")
label: catalog.i18nc("@backuplist:label", "Plugins") label: catalog.i18nc("@backuplist:label", "Plugins")
value: backupDetailsData.metadata.plugin_count value: backupDetailsData.metadata.plugin_count
} }

View file

@ -69,7 +69,7 @@ Item
BackupListFooter BackupListFooter
{ {
id: backupListFooter id: backupListFooter
showInfoButton: backupList.model.length > 4
} }
} }
} }

View file

@ -1,14 +1,16 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import argparse #To run the engine in debug mode if the front-end is in debug mode. import argparse #To run the engine in debug mode if the front-end is in debug mode.
from collections import defaultdict from collections import defaultdict
import os import os
from PyQt5.QtCore import QObject, QTimer, pyqtSlot from PyQt5.QtCore import QObject, QTimer, QUrl, pyqtSlot
import sys import sys
from time import time from time import time
from typing import Any, cast, Dict, List, Optional, Set, TYPE_CHECKING from typing import Any, cast, Dict, List, Optional, Set, TYPE_CHECKING
from PyQt5.QtGui import QDesktopServices, QImage
from UM.Backend.Backend import Backend, BackendState from UM.Backend.Backend import Backend, BackendState
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Signal import Signal from UM.Signal import Signal
@ -24,6 +26,8 @@ from UM.Tool import Tool #For typing.
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from cura.Settings.ExtruderManager import ExtruderManager from cura.Settings.ExtruderManager import ExtruderManager
from cura.Snapshot import Snapshot
from cura.Utils.Threading import call_on_qt_thread
from .ProcessSlicedLayersJob import ProcessSlicedLayersJob from .ProcessSlicedLayersJob import ProcessSlicedLayersJob
from .StartSliceJob import StartSliceJob, StartJobResult from .StartSliceJob import StartSliceJob, StartJobResult
@ -153,6 +157,20 @@ class CuraEngineBackend(QObject, Backend):
self.determineAutoSlicing() self.determineAutoSlicing()
application.getPreferences().preferenceChanged.connect(self._onPreferencesChanged) application.getPreferences().preferenceChanged.connect(self._onPreferencesChanged)
self._slicing_error_message = Message(
text = catalog.i18nc("@message", "Slicing failed with an unexpected error. Please consider reporting a bug on our issue tracker."),
title = catalog.i18nc("@message:title", "Slicing failed")
)
self._slicing_error_message.addAction(
action_id = "report_bug",
name = catalog.i18nc("@message:button", "Report a bug"),
description = catalog.i18nc("@message:description", "Report a bug on Ultimaker Cura's issue tracker."),
icon = "[no_icon]"
)
self._slicing_error_message.actionTriggered.connect(self._reportBackendError)
self._snapshot = None #type: Optional[QImage]
application.initializationFinished.connect(self.initialize) application.initializationFinished.connect(self.initialize)
def initialize(self) -> None: def initialize(self) -> None:
@ -241,9 +259,27 @@ class CuraEngineBackend(QObject, Backend):
self.markSliceAll() self.markSliceAll()
self.slice() self.slice()
@call_on_qt_thread # must be called from the main thread because of OpenGL
def _createSnapshot(self) -> None:
self._snapshot = None
if not CuraApplication.getInstance().isVisible:
Logger.log("w", "Can't create snapshot when renderer not initialized.")
return
Logger.log("i", "Creating thumbnail image (just before slice)...")
try:
self._snapshot = Snapshot.snapshot(width = 300, height = 300)
except:
Logger.logException("w", "Failed to create snapshot image")
self._snapshot = None # Failing to create thumbnail should not fail creation of UFP
def getLatestSnapshot(self) -> Optional[QImage]:
return self._snapshot
def slice(self) -> None: def slice(self) -> None:
"""Perform a slice of the scene.""" """Perform a slice of the scene."""
self._createSnapshot()
Logger.log("i", "Starting to slice...") Logger.log("i", "Starting to slice...")
self._slice_start_time = time() self._slice_start_time = time()
if not self._build_plates_to_be_sliced: if not self._build_plates_to_be_sliced:
@ -331,7 +367,6 @@ class CuraEngineBackend(QObject, Backend):
def _onStartSliceCompleted(self, job: StartSliceJob) -> None: def _onStartSliceCompleted(self, job: StartSliceJob) -> None:
"""Event handler to call when the job to initiate the slicing process is """Event handler to call when the job to initiate the slicing process is
completed. completed.
When the start slice job is successfully completed, it will be happily When the start slice job is successfully completed, it will be happily
@ -899,9 +934,22 @@ class CuraEngineBackend(QObject, Backend):
if not self._restart: if not self._restart:
if self._process: # type: ignore if self._process: # type: ignore
Logger.log("d", "Backend quit with return code %s. Resetting process and socket.", self._process.wait()) # type: ignore return_code = self._process.wait()
if return_code != 0:
Logger.log("e", f"Backend exited abnormally with return code {return_code}!")
self._slicing_error_message.show()
self.setState(BackendState.Error)
self.stopSlicing()
else:
Logger.log("d", "Backend finished slicing. Resetting process and socket.")
self._process = None # type: ignore self._process = None # type: ignore
def _reportBackendError(self, _message_id: str, _action_id: str) -> None:
"""
Triggered when the user wants to report an error in the back-end.
"""
QDesktopServices.openUrl(QUrl("https://github.com/Ultimaker/Cura/issues/new/choose"))
def _onGlobalStackChanged(self) -> None: def _onGlobalStackChanged(self) -> None:
"""Called when the global container stack changes""" """Called when the global container stack changes"""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 470 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View file

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

After

Width:  |  Height:  |  Size: 184 B

View file

@ -0,0 +1,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

View file

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

After

Width:  |  Height:  |  Size: 646 B

View file

@ -0,0 +1,159 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Popup
{
id: base
padding: UM.Theme.getSize("default_margin").width
closePolicy: Popup.CloseOnEscape
focus: true
modal: true
background: Cura.RoundedRectangle
{
cornerSide: Cura.RoundedRectangle.Direction.All
border.color: UM.Theme.getColor("lining")
border.width: UM.Theme.getSize("default_lining").width
radius: UM.Theme.getSize("default_radius").width
width: parent.width
height: parent.height
color: UM.Theme.getColor("main_background")
}
Connections
{
target: manager
function onCreatingNewProjectStatusChanged(status)
{
if (status == DF.RetrievalStatus.Success)
{
base.close();
}
}
}
onOpened:
{
newProjectNameTextField.text = ""
newProjectNameTextField.focus = true
}
Label
{
id: createNewLibraryProjectLabel
text: "Create new Library project"
font: UM.Theme.getFont("medium")
color: UM.Theme.getColor("small_button_text")
anchors
{
top: parent.top
left: parent.left
right: parent.right
}
}
Label
{
id: projectNameLabel
text: "Project Name"
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("text")
anchors
{
top: createNewLibraryProjectLabel.bottom
topMargin: UM.Theme.getSize("default_margin").width
left: parent.left
right: parent.right
}
}
Cura.TextField
{
id: newProjectNameTextField
width: parent.width
anchors
{
top: projectNameLabel.bottom
topMargin: UM.Theme.getSize("thin_margin").width
left: parent.left
right: parent.right
}
validator: RegExpValidator
{
regExp: /^[^\\\/\*\?\|\[\]]{0,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
}
}

View file

@ -0,0 +1,61 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Window
{
id: digitalFactoryOpenDialogBase
title: "Open file from Library"
modality: Qt.ApplicationModal
width: 800 * screenScaleFactor
height: 600 * screenScaleFactor
minimumWidth: 800 * screenScaleFactor
minimumHeight: 600 * screenScaleFactor
Shortcut
{
sequence: "Esc"
onActivated: digitalFactoryOpenDialogBase.close()
}
color: UM.Theme.getColor("main_background")
SelectProjectPage
{
visible: manager.selectedProjectIndex == -1
createNewProjectButtonVisible: false
}
OpenProjectFilesPage
{
visible: manager.selectedProjectIndex >= 0
onOpenFilePressed: digitalFactoryOpenDialogBase.close()
}
BusyIndicator
{
// Shows up while Cura is waiting to receive the user's projects from the digital factory library
id: retrievingProjectsBusyIndicator
anchors {
verticalCenter: parent.verticalCenter
horizontalCenter: parent.horizontalCenter
}
width: parent.width / 4
height: width
visible: manager.retrievingProjectsStatus == DF.RetrievalStatus.InProgress
running: visible
palette.dark: UM.Theme.getColor("text")
}
}

View file

@ -0,0 +1,62 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Window
{
id: digitalFactorySaveDialogBase
title: "Save Cura project to Library"
modality: Qt.ApplicationModal
width: 800 * screenScaleFactor
height: 600 * screenScaleFactor
minimumWidth: 800 * screenScaleFactor
minimumHeight: 600 * screenScaleFactor
Shortcut
{
sequence: "Esc"
onActivated: digitalFactorySaveDialogBase.close()
}
color: UM.Theme.getColor("main_background")
SelectProjectPage
{
visible: manager.selectedProjectIndex == -1
createNewProjectButtonVisible: true
}
SaveProjectFilesPage
{
visible: manager.selectedProjectIndex >= 0
onSavePressed: digitalFactorySaveDialogBase.close()
onSelectDifferentProjectPressed: manager.clearProjectSelection()
}
BusyIndicator
{
// Shows up while Cura is waiting to receive the user's projects from the digital factory library
id: retrievingProjectsBusyIndicator
anchors {
verticalCenter: parent.verticalCenter
horizontalCenter: parent.horizontalCenter
}
width: parent.width / 4
height: width
visible: manager.retrievingProjectsStatus == DF.RetrievalStatus.InProgress
running: visible
palette.dark: UM.Theme.getColor("text")
}
}

View file

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

View file

@ -0,0 +1,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)
}
}

View file

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

View file

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

View file

@ -0,0 +1,231 @@
// Copyright (C) 2021 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import QtQuick.Layouts 1.1
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Item
{
id: base
width: parent.width
height: parent.height
property bool createNewProjectButtonVisible: true
anchors
{
top: parent.top
bottom: parent.bottom
left: parent.left
right: parent.right
margins: UM.Theme.getSize("default_margin").width
}
RowLayout
{
id: headerRow
anchors
{
top: parent.top
left: parent.left
right: parent.right
}
height: childrenRect.height
spacing: UM.Theme.getSize("default_margin").width
Cura.TextField
{
id: searchBar
Layout.fillWidth: true
implicitHeight: createNewProjectButton.height
onTextEdited: manager.projectFilter = text //Update the search filter when editing this text field.
leftIcon: UM.Theme.getIcon("Magnifier")
placeholderText: "Search"
}
Cura.SecondaryButton
{
id: createNewProjectButton
text: "New Library project"
visible: createNewProjectButtonVisible && manager.userAccountCanCreateNewLibraryProject && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
onClicked:
{
createNewProjectPopup.open()
}
busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress
}
Cura.SecondaryButton
{
id: upgradePlanButton
text: "Upgrade plan"
iconSource: UM.Theme.getIcon("LinkExternal")
visible: createNewProjectButtonVisible && !manager.userAccountCanCreateNewLibraryProject && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
tooltip: "You have reached the maximum number of projects allowed by your subscription. Please upgrade to the Professional subscription to create more projects."
tooltipWidth: parent.width * 0.5
onClicked: Qt.openUrlExternally("https://ultimaker.com/software/ultimaker-essentials/sign-up-cura?utm_source=cura&utm_medium=software&utm_campaign=lib-max")
}
}
Item
{
id: noLibraryProjectsContainer
anchors
{
top: parent.top
bottom: parent.bottom
left: parent.left
right: parent.right
}
visible: manager.digitalFactoryProjectModel.count == 0 && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
Column
{
anchors.centerIn: parent
spacing: UM.Theme.getSize("thin_margin").height
Image
{
id: digitalFactoryImage
anchors.horizontalCenter: parent.horizontalCenter
source: searchBar.text === "" ? "../images/digital_factory.svg" : "../images/projects_not_found.svg"
fillMode: Image.PreserveAspectFit
width: parent.width - 2 * UM.Theme.getSize("thick_margin").width
}
Label
{
id: noLibraryProjectsLabel
anchors.horizontalCenter: parent.horizontalCenter
text: searchBar.text === "" ? "It appears that you don't have any projects in the Library yet." : "No projects found that match the search query."
font: UM.Theme.getFont("medium")
color: UM.Theme.getColor("text")
}
Cura.TertiaryButton
{
id: visitDigitalLibraryButton
anchors.horizontalCenter: parent.horizontalCenter
text: "Visit Digital Library"
onClicked: Qt.openUrlExternally(CuraApplication.ultimakerDigitalFactoryUrl + "/app/library")
visible: searchBar.text === "" //Show the link to Digital Library when there are no projects in the user's Library.
}
}
}
Item
{
id: projectListContainer
anchors
{
top: headerRow.bottom
topMargin: UM.Theme.getSize("default_margin").height
bottom: parent.bottom
left: parent.left
right: parent.right
}
visible: manager.digitalFactoryProjectModel.count > 0
// Use a flickable and a column with a repeater instead of a ListView in a ScrollView, because the ScrollView cannot
// have additional children (aside from the view inside it), which wouldn't allow us to add the LoadMoreProjectsCard
// in it.
Flickable
{
id: flickableView
clip: true
contentWidth: parent.width
contentHeight: projectsListView.implicitHeight
anchors.fill: parent
ScrollBar.vertical: ScrollBar
{
// Vertical ScrollBar, styled similarly to the scrollBar in the settings panel
id: verticalScrollBar
visible: flickableView.contentHeight > flickableView.height
background: Rectangle
{
implicitWidth: UM.Theme.getSize("scrollbar").width
radius: Math.round(implicitWidth / 2)
color: UM.Theme.getColor("scrollbar_background")
}
contentItem: Rectangle
{
id: scrollViewHandle
implicitWidth: UM.Theme.getSize("scrollbar").width
radius: Math.round(implicitWidth / 2)
color: verticalScrollBar.pressed ? UM.Theme.getColor("scrollbar_handle_down") : verticalScrollBar.hovered ? UM.Theme.getColor("scrollbar_handle_hover") : UM.Theme.getColor("scrollbar_handle")
Behavior on color { ColorAnimation { duration: 50; } }
}
}
Column
{
id: projectsListView
width: verticalScrollBar.visible ? parent.width - verticalScrollBar.width - UM.Theme.getSize("default_margin").width : parent.width
anchors.top: parent.top
spacing: UM.Theme.getSize("narrow_margin").width
Repeater
{
model: manager.digitalFactoryProjectModel
delegate: ProjectSummaryCard
{
id: projectSummaryCard
imageSource: model.thumbnailUrl || "../images/placeholder.svg"
projectNameText: model.displayName
projectUsernameText: model.username
projectLastUpdatedText: "Last updated: " + model.lastUpdated
onClicked:
{
manager.selectedProjectIndex = index
}
}
}
LoadMoreProjectsCard
{
id: loadMoreProjectsCard
height: UM.Theme.getSize("toolbox_thumbnail_small").height
width: parent.width
visible: manager.digitalFactoryProjectModel.count > 0
hasMoreProjectsToLoad: manager.hasMoreProjectsToLoad
onClicked:
{
manager.loadMoreProjects()
}
}
}
}
}
CreateNewProjectPopup
{
id: createNewProjectPopup
width: 400 * screenScaleFactor
height: 220 * screenScaleFactor
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
}
}

View file

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

View file

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

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