Merge branch 'master' into UMH-2021_ribbed_vaults_infill
2
.github/ISSUE_TEMPLATE.md
vendored
@ -1,5 +1,5 @@
|
||||
---
|
||||
name: Bug report
|
||||
name: Old Bug report
|
||||
about: Create a report to help us fix issues.
|
||||
title: ''
|
||||
labels: 'Type: Bug'
|
||||
|
49
.github/ISSUE_TEMPLATE/bug-report.md
vendored
@ -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
@ -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
@ -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!
|
23
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -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.)
|
44
.github/ISSUE_TEMPLATE/featurerequest.yaml
vendored
Normal 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.
|
11
CITATION.cff
Normal file
@ -0,0 +1,11 @@
|
||||
# YAML 1.2
|
||||
---
|
||||
authors:
|
||||
cff-version: "1.1.0"
|
||||
date-released: 2021-06-28
|
||||
license: "LGPL-3.0"
|
||||
message: "If you use this software, please cite it using these metadata."
|
||||
repository-code: "https://github.com/ultimaker/cura/"
|
||||
title: "Ultimaker Cura"
|
||||
version: "4.10.0"
|
||||
...
|
@ -28,12 +28,12 @@ set(CURA_CLOUD_ACCOUNT_API_ROOT "" CACHE STRING "Alternative Cura cloud account
|
||||
set(CURA_MARKETPLACE_ROOT "" CACHE STRING "Alternative Marketplace 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)
|
||||
|
||||
|
||||
# FIXME: The new FindPython3 finds the system's Python3.6 reather than the Python3.5 that we built for Cura's environment.
|
||||
# FIXME: The new FindPython3 finds the system's Python3.6 rather than the Python3.5 that we built for Cura's environment.
|
||||
# So we're using the old method here, with FindPythonInterp for now.
|
||||
find_package(PythonInterp 3 REQUIRED)
|
||||
|
||||
@ -82,11 +82,11 @@ if(NOT APPLE AND NOT WIN32)
|
||||
install(FILES ${CMAKE_BINARY_DIR}/CuraVersion.py
|
||||
DESTINATION lib${LIB_SUFFIX}/python${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/site-packages/cura)
|
||||
endif()
|
||||
install(FILES ${CMAKE_BINARY_DIR}/cura.desktop
|
||||
install(FILES ${CMAKE_BINARY_DIR}/com.ultimaker.cura.desktop
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
|
||||
install(FILES ${CMAKE_SOURCE_DIR}/resources/images/cura-icon.png
|
||||
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)
|
||||
install(FILES cura.sharedmimeinfo
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/mime/packages/
|
||||
|
14
README.md
@ -10,13 +10,13 @@ For crashes and similar issues, please attach the following information:
|
||||
|
||||
* (On Windows) The log as produced by dxdiag (start -> run -> dxdiag -> save output)
|
||||
* The Cura GUI log file, located at
|
||||
* `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
|
||||
* `$USER/Library/Application Support/cura/<Cura version>/cura.log` (OSX)
|
||||
* `$USER/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux)
|
||||
* `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
|
||||
* `$HOME/Library/Application Support/cura/<Cura version>/cura.log` (OSX)
|
||||
* `$HOME/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux)
|
||||
|
||||
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
|
||||
|
||||
For additional support, you could also ask in the #cura channel on FreeNode IRC. For help with development, there is also the #cura-dev channel.
|
||||
For additional support, you could also ask in the [#cura channel](https://web.libera.chat/#cura) on [libera.chat](https://libera.chat/). For help with development, there is also the [#cura-dev channel](https://web.libera.chat/#cura-dev).
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
@ -26,10 +26,16 @@ Dependencies
|
||||
* [PySerial](https://github.com/pyserial/pyserial) Only required for USB printing support.
|
||||
* [python-zeroconf](https://github.com/jstasiak/python-zeroconf) Only required to detect mDNS-enabled printers.
|
||||
|
||||
For a list of required Python packages, with their recommended version, see `requirements.txt`.
|
||||
|
||||
This list is not exhaustive at the moment, please check the links in the next section for more details.
|
||||
|
||||
Build scripts
|
||||
-------------
|
||||
Please check out [cura-build](https://github.com/Ultimaker/cura-build) for detailed building instructions.
|
||||
|
||||
If you want to build the entire environment from scratch before building Cura as well, [cura-build-environment](https://github.com/Ultimaker/cura-build) might be a starting point before cura-build. (Again, see cura-build for more details.)
|
||||
|
||||
Running from Source
|
||||
-------------
|
||||
Please check our [Wiki page](https://github.com/Ultimaker/Cura/wiki/Running-Cura-from-Source) for details about running Cura from source.
|
||||
|
@ -4,7 +4,7 @@
|
||||
include(CTest)
|
||||
include(CMakeParseArguments)
|
||||
|
||||
# FIXME: The new FindPython3 finds the system's Python3.6 reather than the Python3.5 that we built for Cura's environment.
|
||||
# FIXME: The new FindPython3 finds the system's Python3.6 rather than the Python3.5 that we built for Cura's environment.
|
||||
# So we're using the old method here, with FindPythonInterp for now.
|
||||
find_package(PythonInterp 3 REQUIRED)
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Copyright 2016 Richard Hughes <richard@hughsie.com> -->
|
||||
<component type="desktop">
|
||||
<id>cura.desktop</id>
|
||||
<id>com.ultimaker.cura.desktop</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>LGPL-3.0 and CC-BY-SA-4.0</project_license>
|
||||
<name>Cura</name>
|
||||
@ -24,8 +24,10 @@
|
||||
</ul>
|
||||
</description>
|
||||
<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>
|
||||
<url type="homepage">https://ultimaker.com/en/products/cura-software?utm_source=cura&utm_medium=software&utm_campaign=resources</url>
|
||||
<url type="homepage">https://ultimaker.com/software/ultimaker-cura?utm_source=cura&utm_medium=software&utm_campaign=cura-update-linux</url>
|
||||
<translation type="gettext">Cura</translation>
|
||||
</component>
|
@ -40,7 +40,7 @@ class Account(QObject):
|
||||
"""
|
||||
|
||||
# The interval in which sync services are automatically triggered
|
||||
SYNC_INTERVAL = 30.0 # seconds
|
||||
SYNC_INTERVAL = 60.0 # seconds
|
||||
Q_ENUMS(SyncState)
|
||||
|
||||
loginStateChanged = pyqtSignal(bool)
|
||||
@ -58,6 +58,11 @@ class Account(QObject):
|
||||
manualSyncEnabledChanged = pyqtSignal(bool)
|
||||
updatePackagesEnabledChanged = pyqtSignal(bool)
|
||||
|
||||
CLIENT_SCOPES = "account.user.read drive.backup.read drive.backup.write packages.download " \
|
||||
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write " \
|
||||
"library.project.read library.project.write cura.printjob.read cura.printjob.write " \
|
||||
"cura.mesh.read cura.mesh.write"
|
||||
|
||||
def __init__(self, application: "CuraApplication", parent = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._application = application
|
||||
@ -79,10 +84,7 @@ class Account(QObject):
|
||||
CALLBACK_PORT=self._callback_port,
|
||||
CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port),
|
||||
CLIENT_ID="um----------------------------ultimaker_cura",
|
||||
CLIENT_SCOPES="account.user.read drive.backup.read drive.backup.write packages.download "
|
||||
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write "
|
||||
"library.project.read library.project.write cura.printjob.read cura.printjob.write "
|
||||
"cura.mesh.read cura.mesh.write",
|
||||
CLIENT_SCOPES=self.CLIENT_SCOPES,
|
||||
AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data",
|
||||
AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root),
|
||||
AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root)
|
||||
@ -107,7 +109,6 @@ class Account(QObject):
|
||||
self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged)
|
||||
self._authorization_service.loadAuthDataFromPreferences()
|
||||
|
||||
|
||||
@pyqtProperty(int, notify=syncStateChanged)
|
||||
def syncState(self):
|
||||
return self._sync_state
|
||||
@ -176,7 +177,10 @@ class Account(QObject):
|
||||
if error_message:
|
||||
if self._error_message:
|
||||
self._error_message.hide()
|
||||
self._error_message = Message(error_message, title = i18n_catalog.i18nc("@info:title", "Login failed"))
|
||||
Logger.log("w", "Failed to login: %s", error_message)
|
||||
self._error_message = Message(error_message,
|
||||
title = i18n_catalog.i18nc("@info:title", "Login failed"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
self._error_message.show()
|
||||
self._logged_in = False
|
||||
self.loginStateChanged.emit(False)
|
||||
@ -207,7 +211,7 @@ class Account(QObject):
|
||||
if self._update_timer.isActive():
|
||||
self._update_timer.stop()
|
||||
elif self._sync_state == SyncState.SYNCING:
|
||||
Logger.warning("Starting a new sync while previous sync was not completed\n{}", str(self._sync_services))
|
||||
Logger.debug("Starting a new sync while previous sync was not completed")
|
||||
|
||||
self.syncRequested.emit()
|
||||
|
||||
|
@ -13,7 +13,7 @@ DEFAULT_CURA_DEBUG_MODE = False
|
||||
# Each release has a fixed SDK version coupled with it. It doesn't make sense to make it configurable because, for
|
||||
# example Cura 3.2 with SDK version 6.1 will not work. So the SDK version is hard-coded here and left out of the
|
||||
# CuraVersion.py.in template.
|
||||
CuraSDKVersion = "7.4.0"
|
||||
CuraSDKVersion = "7.8.0"
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraAppName # type: ignore
|
||||
|
@ -147,6 +147,8 @@ class ArrangeObjectsAllBuildPlatesJob(Job):
|
||||
status_message.hide()
|
||||
|
||||
if not found_solution_for_all:
|
||||
no_full_solution_message = Message(i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"),
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"))
|
||||
no_full_solution_message = Message(i18n_catalog.i18nc("@info:status",
|
||||
"Unable to find a location within the build volume for all objects"),
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
no_full_solution_message.show()
|
||||
|
@ -39,6 +39,7 @@ class ArrangeObjectsJob(Job):
|
||||
no_full_solution_message = Message(
|
||||
i18n_catalog.i18nc("@info:status",
|
||||
"Unable to find a location within the build volume for all objects"),
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"))
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
no_full_solution_message.show()
|
||||
self.finished.emit(self)
|
||||
|
@ -36,6 +36,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
|
||||
found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
|
||||
node_items: A list of the nodes return by libnest2d, which contain the new positions on the buildplate
|
||||
"""
|
||||
spacing = int(1.5 * factor) # 1.5mm spacing.
|
||||
|
||||
machine_width = build_volume.getWidth()
|
||||
machine_depth = build_volume.getDepth()
|
||||
@ -75,7 +76,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
|
||||
# Clip the disallowed areas so that they don't overlap the bounding box (The arranger chokes otherwise)
|
||||
clipped_area = area.intersectionConvexHulls(build_plate_polygon)
|
||||
|
||||
if clipped_area.getPoints() is not None: # numpy array has to be explicitly checked against None
|
||||
if clipped_area.getPoints() is not None and len(clipped_area.getPoints()) > 2: # numpy array has to be explicitly checked against None
|
||||
for point in clipped_area.getPoints():
|
||||
converted_points.append(Point(int(point[0] * factor), int(point[1] * factor)))
|
||||
|
||||
@ -88,7 +89,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
|
||||
converted_points = []
|
||||
hull_polygon = node.callDecoration("getConvexHull")
|
||||
|
||||
if hull_polygon is not None and hull_polygon.getPoints() is not None: # numpy array has to be explicitly checked against None
|
||||
if hull_polygon is not None and hull_polygon.getPoints() is not None and len(hull_polygon.getPoints()) > 2: # numpy array has to be explicitly checked against None
|
||||
for point in hull_polygon.getPoints():
|
||||
converted_points.append(Point(point[0] * factor, point[1] * factor))
|
||||
item = Item(converted_points)
|
||||
@ -99,7 +100,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
|
||||
config = NfpConfig()
|
||||
config.accuracy = 1.0
|
||||
|
||||
num_bins = nest(node_items, build_plate_bounding_box, 10000, config)
|
||||
num_bins = nest(node_items, build_plate_bounding_box, spacing, config)
|
||||
|
||||
# Strip the fixed items (previously placed) and the disallowed areas from the results again.
|
||||
node_items = list(filter(lambda item: not item.isFixed(), node_items))
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
import numpy
|
||||
import copy
|
||||
from typing import Optional, Tuple, TYPE_CHECKING
|
||||
from typing import Optional, Tuple, TYPE_CHECKING, Union
|
||||
|
||||
from UM.Math.Polygon import Polygon
|
||||
|
||||
@ -14,14 +14,14 @@ if TYPE_CHECKING:
|
||||
class ShapeArray:
|
||||
"""Polygon representation as an array for use with :py:class:`cura.Arranging.Arrange.Arrange`"""
|
||||
|
||||
def __init__(self, arr: numpy.array, offset_x: float, offset_y: float, scale: float = 1) -> None:
|
||||
def __init__(self, arr: numpy.ndarray, offset_x: float, offset_y: float, scale: float = 1) -> None:
|
||||
self.arr = arr
|
||||
self.offset_x = offset_x
|
||||
self.offset_y = offset_y
|
||||
self.scale = scale
|
||||
|
||||
@classmethod
|
||||
def fromPolygon(cls, vertices: numpy.array, scale: float = 1) -> "ShapeArray":
|
||||
def fromPolygon(cls, vertices: numpy.ndarray, scale: float = 1) -> "ShapeArray":
|
||||
"""Instantiate from a bunch of vertices
|
||||
|
||||
:param vertices:
|
||||
@ -98,7 +98,7 @@ class ShapeArray:
|
||||
return offset_shape_arr, hull_shape_arr
|
||||
|
||||
@classmethod
|
||||
def arrayFromPolygon(cls, shape: Tuple[int, int], vertices: numpy.array) -> numpy.array:
|
||||
def arrayFromPolygon(cls, shape: Union[Tuple[int, int], numpy.ndarray], vertices: numpy.ndarray) -> numpy.ndarray:
|
||||
"""Create :py:class:`numpy.ndarray` with dimensions defined by shape
|
||||
|
||||
Fills polygon defined by vertices with ones, all other values zero
|
||||
@ -110,7 +110,7 @@ class ShapeArray:
|
||||
:return: numpy array with dimensions defined by shape
|
||||
"""
|
||||
|
||||
base_array = numpy.zeros(shape, dtype = numpy.int32) # Initialize your array of zeros
|
||||
base_array = numpy.zeros(shape, dtype = numpy.int32) # type: ignore # Initialize your array of zeros
|
||||
|
||||
fill = numpy.ones(base_array.shape) * True # Initialize boolean array defining shape fill
|
||||
|
||||
@ -126,7 +126,7 @@ class ShapeArray:
|
||||
return base_array
|
||||
|
||||
@classmethod
|
||||
def _check(cls, p1: numpy.array, p2: numpy.array, base_array: numpy.array) -> Optional[numpy.array]:
|
||||
def _check(cls, p1: numpy.ndarray, p2: numpy.ndarray, base_array: numpy.ndarray) -> Optional[numpy.ndarray]:
|
||||
"""Return indices that mark one side of the line, used by arrayFromPolygon
|
||||
|
||||
Uses the line defined by p1 and p2 to check array of
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from PyQt5.QtCore import QTimer
|
||||
@ -6,6 +6,8 @@ from typing import Any, TYPE_CHECKING
|
||||
|
||||
from UM.Logger import Logger
|
||||
|
||||
import time
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
@ -56,8 +58,8 @@ class AutoSave:
|
||||
|
||||
def _onTimeout(self) -> None:
|
||||
self._saving = True # To prevent the save process from triggering another autosave.
|
||||
Logger.log("d", "Autosaving preferences, instances and profiles")
|
||||
|
||||
save_start_time = time.time()
|
||||
self._application.saveSettings()
|
||||
|
||||
Logger.log("d", "Autosaving preferences, instances and profiles took %s seconds", time.time() - save_start_time)
|
||||
self._saving = False
|
||||
|
@ -5,14 +5,16 @@ import io
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from copy import deepcopy
|
||||
from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile
|
||||
from typing import Dict, Optional, TYPE_CHECKING
|
||||
from typing import Dict, Optional, TYPE_CHECKING, List
|
||||
|
||||
from UM import i18nCatalog
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.Platform import Platform
|
||||
from UM.Resources import Resources
|
||||
from UM.Version import Version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
@ -27,6 +29,11 @@ class Backup:
|
||||
IGNORED_FILES = [r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc"]
|
||||
"""These files should be ignored when making a backup."""
|
||||
|
||||
IGNORED_FOLDERS = [] # type: List[str]
|
||||
|
||||
SECRETS_SETTINGS = ["general/ultimaker_auth_data"]
|
||||
"""Secret preferences that need to obfuscated when making a backup of Cura"""
|
||||
|
||||
catalog = i18nCatalog("cura")
|
||||
"""Re-use translation catalog"""
|
||||
|
||||
@ -43,6 +50,9 @@ class Backup:
|
||||
|
||||
Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir)
|
||||
|
||||
# obfuscate sensitive secrets
|
||||
secrets = self._obfuscate()
|
||||
|
||||
# Ensure all current settings are saved.
|
||||
self._application.saveSettings()
|
||||
|
||||
@ -67,8 +77,9 @@ class Backup:
|
||||
machine_count = max(len([s for s in files if "machine_instances/" in s]) - 1, 0) # If people delete their profiles but not their preferences, it can still make a backup, and report -1 profiles. Server crashes on this.
|
||||
material_count = max(len([s for s in files if "materials/" in s]) - 1, 0)
|
||||
profile_count = max(len([s for s in files if "quality_changes/" in s]) - 1, 0)
|
||||
plugin_count = len([s for s in files if "plugin.json" in s])
|
||||
|
||||
# We don't store plugins anymore, since if you can make backups, you have an account (and the plugins are
|
||||
# on the marketplace anyway)
|
||||
plugin_count = 0
|
||||
# Store the archive and metadata so the BackupManager can fetch them when needed.
|
||||
self.zip_file = buffer.getvalue()
|
||||
self.meta_data = {
|
||||
@ -78,6 +89,8 @@ class Backup:
|
||||
"profile_count": str(profile_count),
|
||||
"plugin_count": str(plugin_count)
|
||||
}
|
||||
# Restore the obfuscated settings
|
||||
self._illuminate(**secrets)
|
||||
|
||||
def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]:
|
||||
"""Make a full archive from the given root path with the given name.
|
||||
@ -85,8 +98,7 @@ class Backup:
|
||||
:param root_path: The root directory to archive recursively.
|
||||
:return: The archive as bytes.
|
||||
"""
|
||||
|
||||
ignore_string = re.compile("|".join(self.IGNORED_FILES))
|
||||
ignore_string = re.compile("|".join(self.IGNORED_FILES + self.IGNORED_FOLDERS))
|
||||
try:
|
||||
archive = ZipFile(buffer, "w", ZIP_DEFLATED)
|
||||
for root, folders, files in os.walk(root_path):
|
||||
@ -99,15 +111,15 @@ class Backup:
|
||||
return archive
|
||||
except (IOError, OSError, BadZipfile) as error:
|
||||
Logger.log("e", "Could not create archive from user data directory: %s", error)
|
||||
self._showMessage(
|
||||
self.catalog.i18nc("@info:backup_failed",
|
||||
"Could not create archive from user data directory: {}".format(error)))
|
||||
self._showMessage(self.catalog.i18nc("@info:backup_failed",
|
||||
"Could not create archive from user data directory: {}".format(error)),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
return None
|
||||
|
||||
def _showMessage(self, message: str) -> None:
|
||||
def _showMessage(self, message: str, message_type: Message.MessageType = Message.MessageType.NEUTRAL) -> None:
|
||||
"""Show a UI message."""
|
||||
|
||||
Message(message, title=self.catalog.i18nc("@info:title", "Backup"), lifetime=30).show()
|
||||
Message(message, title=self.catalog.i18nc("@info:title", "Backup"), message_type = message_type).show()
|
||||
|
||||
def restore(self) -> bool:
|
||||
"""Restore this back-up.
|
||||
@ -118,24 +130,36 @@ class Backup:
|
||||
if not self.zip_file or not self.meta_data or not self.meta_data.get("cura_release", None):
|
||||
# We can restore without the minimum required information.
|
||||
Logger.log("w", "Tried to restore a Cura backup without having proper data or meta data.")
|
||||
self._showMessage(
|
||||
self.catalog.i18nc("@info:backup_failed",
|
||||
"Tried to restore a Cura backup without having proper data or meta data."))
|
||||
self._showMessage(self.catalog.i18nc("@info:backup_failed",
|
||||
"Tried to restore a Cura backup without having proper data or meta data."),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
return False
|
||||
|
||||
current_version = self._application.getVersion()
|
||||
version_to_restore = self.meta_data.get("cura_release", "master")
|
||||
current_version = Version(self._application.getVersion())
|
||||
version_to_restore = Version(self.meta_data.get("cura_release", "master"))
|
||||
|
||||
if current_version < version_to_restore:
|
||||
# Cannot restore version newer than current because settings might have changed.
|
||||
Logger.log("d", "Tried to restore a Cura backup of version {version_to_restore} with cura version {current_version}".format(version_to_restore = version_to_restore, current_version = current_version))
|
||||
self._showMessage(
|
||||
self.catalog.i18nc("@info:backup_failed",
|
||||
"Tried to restore a Cura backup that is higher than the current version."))
|
||||
self._showMessage(self.catalog.i18nc("@info:backup_failed",
|
||||
"Tried to restore a Cura backup that is higher than the current version."),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
return False
|
||||
|
||||
# Get the current secrets and store since the back-up doesn't contain those
|
||||
secrets = self._obfuscate()
|
||||
|
||||
version_data_dir = Resources.getDataStoragePath()
|
||||
archive = ZipFile(io.BytesIO(self.zip_file), "r")
|
||||
try:
|
||||
archive = ZipFile(io.BytesIO(self.zip_file), "r")
|
||||
except LookupError as e:
|
||||
Logger.log("d", f"The following error occurred while trying to restore a Cura backup: {str(e)}")
|
||||
Message(self.catalog.i18nc("@info:backup_failed",
|
||||
"The following error occurred while trying to restore a Cura backup:") + str(e),
|
||||
title = self.catalog.i18nc("@info:title", "Backup"),
|
||||
message_type = Message.MessageType.ERROR).show()
|
||||
|
||||
return False
|
||||
extracted = self._extractArchive(archive, version_data_dir)
|
||||
|
||||
# Under Linux, preferences are stored elsewhere, so we copy the file to there.
|
||||
@ -146,6 +170,12 @@ class Backup:
|
||||
Logger.log("d", "Moving preferences file from %s to %s", 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
|
||||
|
||||
@staticmethod
|
||||
@ -167,9 +197,36 @@ class Backup:
|
||||
Logger.log("d", "Removing current data in location: %s", target_path)
|
||||
Resources.factoryReset()
|
||||
Logger.log("d", "Extracting backup to location: %s", target_path)
|
||||
try:
|
||||
archive.extractall(target_path)
|
||||
except (PermissionError, EnvironmentError):
|
||||
Logger.logException("e", "Unable to extract the backup due to permission or file system errors.")
|
||||
return False
|
||||
name_list = archive.namelist()
|
||||
for archive_filename in name_list:
|
||||
try:
|
||||
archive.extract(archive_filename, target_path)
|
||||
except (PermissionError, EnvironmentError):
|
||||
Logger.logException("e", f"Unable to extract the file {archive_filename} from the backup due to permission or file system errors.")
|
||||
CuraApplication.getInstance().processEvents()
|
||||
return True
|
||||
|
||||
def _obfuscate(self) -> Dict[str, str]:
|
||||
"""
|
||||
Obfuscate and remove the secret preferences that are specified in SECRETS_SETTINGS
|
||||
|
||||
:return: a dictionary of the removed secrets. Note: the '/' is replaced by '__'
|
||||
"""
|
||||
preferences = self._application.getPreferences()
|
||||
secrets = {}
|
||||
for secret in self.SECRETS_SETTINGS:
|
||||
secrets[secret.replace("/", "__")] = deepcopy(preferences.getValue(secret))
|
||||
preferences.setValue(secret, None)
|
||||
self._application.savePreferences()
|
||||
return secrets
|
||||
|
||||
def _illuminate(self, **kwargs) -> None:
|
||||
"""
|
||||
Restore the obfuscated settings
|
||||
|
||||
:param kwargs: a dict of obscured preferences. Note: the '__' of the keys will be replaced by '/'
|
||||
"""
|
||||
preferences = self._application.getPreferences()
|
||||
for key, value in kwargs.items():
|
||||
preferences.setValue(key.replace("__", "/"), value)
|
||||
self._application.savePreferences()
|
||||
|
@ -4,6 +4,7 @@
|
||||
from typing import Dict, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
from UM.Logger import Logger
|
||||
from UM.Version import Version
|
||||
from cura.Backups.Backup import Backup
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -52,6 +53,7 @@ class BackupsManager:
|
||||
|
||||
backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data)
|
||||
restored = backup.restore()
|
||||
|
||||
if restored:
|
||||
# At this point, Cura will need to restart for the changes to take effect.
|
||||
# We don't want to store the data at this point as that would override the just-restored backup.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import numpy
|
||||
@ -95,9 +95,11 @@ class BuildVolume(SceneNode):
|
||||
self._edge_disallowed_size = None
|
||||
|
||||
self._build_volume_message = Message(catalog.i18nc("@info:status",
|
||||
"The build volume height has been reduced due to the value of the"
|
||||
" \"Print Sequence\" setting to prevent the gantry from colliding"
|
||||
" with printed models."), title = catalog.i18nc("@info:title", "Build Volume"))
|
||||
"The build volume height has been reduced due to the value of the"
|
||||
" \"Print Sequence\" setting to prevent the gantry from colliding"
|
||||
" with printed models."),
|
||||
title = catalog.i18nc("@info:title", "Build Volume"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
|
||||
self._global_container_stack = None # type: Optional[GlobalStack]
|
||||
|
||||
@ -916,6 +918,8 @@ class BuildVolume(SceneNode):
|
||||
return {}
|
||||
|
||||
for area in self._global_container_stack.getProperty("machine_disallowed_areas", "value"):
|
||||
if len(area) == 0:
|
||||
continue # Numpy doesn't deal well with 0-length arrays, since it can't determine the dimensionality of them.
|
||||
polygon = Polygon(numpy.array(area, numpy.float32))
|
||||
polygon = polygon.getMinkowskiHull(Polygon.approximatedCircle(border_size))
|
||||
machine_disallowed_polygons.append(polygon)
|
||||
|
@ -35,7 +35,7 @@ class CuraActions(QObject):
|
||||
# Starting a web browser from a signal handler connected to a menu will crash on windows.
|
||||
# So instead, defer the call to the next run of the event loop, since that does work.
|
||||
# Note that weirdly enough, only signal handlers that open a web browser fail like that.
|
||||
event = CallFunctionEvent(self._openUrl, [QUrl("https://ultimaker.com/en/resources/manuals/software")], {})
|
||||
event = CallFunctionEvent(self._openUrl, [QUrl("https://ultimaker.com/en/resources/manuals/software?utm_source=cura&utm_medium=software&utm_campaign=dropdown-documentation")], {})
|
||||
cura.CuraApplication.CuraApplication.getInstance().functionEvent(event)
|
||||
|
||||
@pyqtSlot()
|
||||
@ -67,11 +67,15 @@ class CuraActions(QObject):
|
||||
current_node = parent_node
|
||||
parent_node = current_node.getParent()
|
||||
|
||||
# This was formerly done with SetTransformOperation but because of
|
||||
# unpredictable matrix deconstruction it was possible that mirrors
|
||||
# could manifest as rotations. Centering is therefore done by
|
||||
# moving the node to negative whatever its position is:
|
||||
center_operation = TranslateOperation(current_node, -current_node._position)
|
||||
# Find out where the bottom of the object is
|
||||
bbox = current_node.getBoundingBox()
|
||||
if bbox:
|
||||
center_y = current_node.getWorldPosition().y - bbox.bottom
|
||||
else:
|
||||
center_y = 0
|
||||
|
||||
# Move the object so that it's bottom is on to of the buildplate
|
||||
center_operation = TranslateOperation(current_node, Vector(0, center_y, 0), set_position = True)
|
||||
operation.addOperation(center_operation)
|
||||
operation.push()
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import os
|
||||
@ -129,7 +129,7 @@ class CuraApplication(QtApplication):
|
||||
# SettingVersion represents the set of settings available in the machine/extruder definitions.
|
||||
# You need to make sure that this version number needs to be increased if there is any non-backwards-compatible
|
||||
# changes of the settings.
|
||||
SettingVersion = 16
|
||||
SettingVersion = 17
|
||||
|
||||
Created = False
|
||||
|
||||
@ -161,7 +161,7 @@ class CuraApplication(QtApplication):
|
||||
|
||||
self.default_theme = "cura-light"
|
||||
|
||||
self.change_log_url = "https://ultimaker.com/ultimaker-cura-latest-features"
|
||||
self.change_log_url = "https://ultimaker.com/ultimaker-cura-latest-features?utm_source=cura&utm_medium=software&utm_campaign=cura-update-features"
|
||||
|
||||
self._boot_loading_time = time.time()
|
||||
|
||||
@ -257,6 +257,9 @@ class CuraApplication(QtApplication):
|
||||
from cura.CuraPackageManager import CuraPackageManager
|
||||
self._package_manager_class = CuraPackageManager
|
||||
|
||||
from UM.CentralFileStorage import CentralFileStorage
|
||||
CentralFileStorage.setIsEnterprise(ApplicationMetadata.IsEnterpriseVersion)
|
||||
|
||||
@pyqtProperty(str, constant=True)
|
||||
def ultimakerCloudApiRootUrl(self) -> str:
|
||||
return UltimakerCloudConstants.CuraCloudAPIRoot
|
||||
@ -317,7 +320,7 @@ class CuraApplication(QtApplication):
|
||||
super().initialize()
|
||||
|
||||
self._preferences.addPreference("cura/single_instance", False)
|
||||
self._use_single_instance = self._preferences.getValue("cura/single_instance")
|
||||
self._use_single_instance = self._preferences.getValue("cura/single_instance") or self._cli_args.single_instance
|
||||
|
||||
self.__sendCommandToSingleInstance()
|
||||
self._initializeSettingDefinitions()
|
||||
@ -458,15 +461,18 @@ class CuraApplication(QtApplication):
|
||||
|
||||
self._version_upgrade_manager.setCurrentVersions(
|
||||
{
|
||||
("quality", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("quality_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityChangesInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("intent", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.IntentInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("machine_stack", GlobalStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.MachineStack, "application/x-cura-globalstack"),
|
||||
("extruder_train", ExtruderStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.ExtruderStack, "application/x-cura-extruderstack"),
|
||||
("preferences", Preferences.Version * 1000000 + self.SettingVersion): (Resources.Preferences, "application/x-uranium-preferences"),
|
||||
("user", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.UserInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("definition_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.DefinitionChangesContainer, "application/x-uranium-instancecontainer"),
|
||||
("variant", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.VariantInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("quality", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("quality_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityChangesInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("intent", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.IntentInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("machine_stack", GlobalStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.MachineStack, "application/x-cura-globalstack"),
|
||||
("extruder_train", ExtruderStack.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.ExtruderStack, "application/x-cura-extruderstack"),
|
||||
("preferences", Preferences.Version * 1000000 + self.SettingVersion): (Resources.Preferences, "application/x-uranium-preferences"),
|
||||
("user", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.UserInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("definition_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.DefinitionChangesContainer, "application/x-uranium-instancecontainer"),
|
||||
("variant", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.VariantInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("setting_visibility", SettingVisibilityPresetsModel.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.SettingVisibilityPreset, "application/x-uranium-preferences"),
|
||||
("machine", 2): (Resources.DefinitionContainers, "application/x-uranium-definitioncontainer"),
|
||||
("extruder", 2): (Resources.DefinitionContainers, "application/x-uranium-definitioncontainer")
|
||||
}
|
||||
)
|
||||
|
||||
@ -603,6 +609,15 @@ class CuraApplication(QtApplication):
|
||||
@pyqtSlot()
|
||||
def closeApplication(self) -> None:
|
||||
Logger.log("i", "Close application")
|
||||
|
||||
# Workaround: Before closing the window, remove the global stack.
|
||||
# This is necessary because as the main window gets closed, hundreds of QML elements get updated which often
|
||||
# request the global stack. However as the Qt-side of the Machine Manager is being dismantled, the conversion of
|
||||
# the Global Stack to a QObject fails.
|
||||
# If instead we first take down the global stack, PyQt will just convert `None` to `null` which succeeds, and
|
||||
# the QML code then gets `null` as the global stack and can deal with that as it deems fit.
|
||||
self.getMachineManager().setActiveMachine(None)
|
||||
|
||||
main_window = self.getMainWindow()
|
||||
if main_window is not None:
|
||||
main_window.close()
|
||||
@ -695,6 +710,8 @@ class CuraApplication(QtApplication):
|
||||
@pyqtSlot(str)
|
||||
def discardOrKeepProfileChangesClosed(self, option: str) -> None:
|
||||
global_stack = self.getGlobalContainerStack()
|
||||
if global_stack is None:
|
||||
return
|
||||
if option == "discard":
|
||||
for extruder in global_stack.extruderList:
|
||||
extruder.userChanges.clear()
|
||||
@ -894,14 +911,14 @@ class CuraApplication(QtApplication):
|
||||
diagonal = self.getBuildVolume().getDiagonalSize()
|
||||
if diagonal < 1: #No printer added yet. Set a default camera distance for normal-sized printers.
|
||||
diagonal = 375
|
||||
camera.setPosition(Vector(-80, 250, 700) * diagonal / 375)
|
||||
camera.setPosition(Vector(-80, 180, 700) * diagonal / 375)
|
||||
camera.lookAt(Vector(0, 0, 0))
|
||||
controller.getScene().setActiveCamera("3d")
|
||||
|
||||
# Initialize camera tool
|
||||
camera_tool = controller.getTool("CameraTool")
|
||||
if camera_tool:
|
||||
camera_tool.setOrigin(Vector(0, 100, 0))
|
||||
camera_tool.setOrigin(Vector(0, 30, 0))
|
||||
camera_tool.setZoomRange(0.1, 2000)
|
||||
|
||||
# Initialize camera animations
|
||||
@ -1268,10 +1285,11 @@ class CuraApplication(QtApplication):
|
||||
if other_bb is not None:
|
||||
scene_bounding_box = scene_bounding_box + node.getBoundingBox()
|
||||
|
||||
|
||||
if print_information:
|
||||
print_information.setPreSliced(is_block_slicing_node)
|
||||
|
||||
self.getWorkspaceFileHandler().setEnabled(not is_block_slicing_node)
|
||||
|
||||
if not scene_bounding_box:
|
||||
scene_bounding_box = AxisAlignedBox.Null
|
||||
|
||||
@ -1294,9 +1312,9 @@ class CuraApplication(QtApplication):
|
||||
if not isinstance(node, SceneNode):
|
||||
continue
|
||||
if not node.getMeshData() and not node.callDecoration("isGroup"):
|
||||
continue # Node that doesnt have a mesh and is not a group.
|
||||
continue # Node that doesn't have a mesh and is not a group.
|
||||
if node.getParent() and node.getParent().callDecoration("isGroup") or node.getParent().callDecoration("isSliceable"):
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
|
||||
if not node.isSelectable():
|
||||
continue # i.e. node with layer data
|
||||
if not node.callDecoration("isSliceable") and not node.callDecoration("isGroup"):
|
||||
@ -1314,9 +1332,9 @@ class CuraApplication(QtApplication):
|
||||
if not isinstance(node, SceneNode):
|
||||
continue
|
||||
if not node.getMeshData() and not node.callDecoration("isGroup"):
|
||||
continue # Node that doesnt have a mesh and is not a group.
|
||||
continue # Node that doesn't have a mesh and is not a group.
|
||||
if node.getParent() and node.getParent().callDecoration("isGroup"):
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
|
||||
if not node.isSelectable():
|
||||
continue # i.e. node with layer data
|
||||
nodes.append(node)
|
||||
@ -1343,9 +1361,9 @@ class CuraApplication(QtApplication):
|
||||
if not isinstance(node, SceneNode):
|
||||
continue
|
||||
if not node.getMeshData() and not node.callDecoration("isGroup"):
|
||||
continue # Node that doesnt have a mesh and is not a group.
|
||||
continue # Node that doesn't have a mesh and is not a group.
|
||||
if node.getParent() and node.getParent().callDecoration("isGroup"):
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
|
||||
if not node.callDecoration("isSliceable") and not node.callDecoration("isGroup"):
|
||||
continue # i.e. node with layer data
|
||||
nodes.append(node)
|
||||
@ -1372,7 +1390,7 @@ class CuraApplication(QtApplication):
|
||||
continue
|
||||
|
||||
if not node.getMeshData() and not node.callDecoration("isGroup"):
|
||||
continue # Node that doesnt have a mesh and is not a group.
|
||||
continue # Node that doesn't have a mesh and is not a group.
|
||||
|
||||
parent_node = node.getParent()
|
||||
if parent_node and parent_node.callDecoration("isGroup"):
|
||||
@ -1400,11 +1418,11 @@ class CuraApplication(QtApplication):
|
||||
continue
|
||||
|
||||
if not node.getMeshData() and not node.callDecoration("isGroup"):
|
||||
continue # Node that doesnt have a mesh and is not a group.
|
||||
continue # Node that doesn't have a mesh and is not a group.
|
||||
|
||||
parent_node = node.getParent()
|
||||
if parent_node and parent_node.callDecoration("isGroup"):
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
|
||||
|
||||
if not node.isSelectable():
|
||||
continue # i.e. node with layer data
|
||||
@ -1515,12 +1533,8 @@ class CuraApplication(QtApplication):
|
||||
|
||||
# Compute the center of the objects
|
||||
object_centers = []
|
||||
# Forget about the translation that the original objects have
|
||||
zero_translation = Matrix(data=numpy.zeros(3))
|
||||
for mesh, node in zip(meshes, group_node.getChildren()):
|
||||
transformation = node.getLocalTransformation()
|
||||
transformation.setTranslation(zero_translation)
|
||||
transformed_mesh = mesh.getTransformed(transformation)
|
||||
transformed_mesh = mesh.getTransformed(Matrix()) # Forget about the transformations that the original object had.
|
||||
center = transformed_mesh.getCenterPosition()
|
||||
if center is not None:
|
||||
object_centers.append(center)
|
||||
@ -1535,7 +1549,7 @@ class CuraApplication(QtApplication):
|
||||
|
||||
# Move each node to the same position.
|
||||
for mesh, node in zip(meshes, group_node.getChildren()):
|
||||
node.setTransformation(Matrix())
|
||||
node.setTransformation(Matrix()) # Removes any changes in position and rotation.
|
||||
# Align the object around its zero position
|
||||
# and also apply the offset to center it inside the group.
|
||||
node.setPosition(-mesh.getZeroPosition() - offset)
|
||||
@ -1733,8 +1747,9 @@ class CuraApplication(QtApplication):
|
||||
def log(self, 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)
|
||||
def readLocalFile(self, file: QUrl, project_mode: Optional[str] = None, add_to_recent_files: bool = True):
|
||||
@ -1742,6 +1757,7 @@ class CuraApplication(QtApplication):
|
||||
|
||||
: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.
|
||||
: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())
|
||||
if not file.isValid():
|
||||
@ -1762,12 +1778,12 @@ class CuraApplication(QtApplication):
|
||||
if is_project_file and project_mode == "open_as_project":
|
||||
# open as project immediately without presenting a dialog
|
||||
workspace_handler = self.getWorkspaceFileHandler()
|
||||
workspace_handler.readLocalFile(file, add_to_recent_files = add_to_recent_files)
|
||||
workspace_handler.readLocalFile(file, add_to_recent_files_hint = add_to_recent_files)
|
||||
return
|
||||
|
||||
if is_project_file and project_mode == "always_ask":
|
||||
# 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
|
||||
|
||||
# Either the file is a model file or we want to load only models from project. Continue to load models.
|
||||
@ -1784,8 +1800,10 @@ class CuraApplication(QtApplication):
|
||||
if extension in self._non_sliceable_extensions:
|
||||
message = Message(
|
||||
self._i18n_catalog.i18nc("@info:status",
|
||||
"Only one G-code file can be loaded at a time. Skipped importing {0}",
|
||||
filename), title = self._i18n_catalog.i18nc("@info:title", "Warning"))
|
||||
"Only one G-code file can be loaded at a time. Skipped importing {0}",
|
||||
filename),
|
||||
title = self._i18n_catalog.i18nc("@info:title", "Warning"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
message.show()
|
||||
return
|
||||
# If file being loaded is non-slicable file, then prevent loading of any other files
|
||||
@ -1794,8 +1812,10 @@ class CuraApplication(QtApplication):
|
||||
if extension in self._non_sliceable_extensions:
|
||||
message = Message(
|
||||
self._i18n_catalog.i18nc("@info:status",
|
||||
"Can't open any other file if G-code is loading. Skipped importing {0}",
|
||||
filename), title = self._i18n_catalog.i18nc("@info:title", "Error"))
|
||||
"Can't open any other file if G-code is loading. Skipped importing {0}",
|
||||
filename),
|
||||
title = self._i18n_catalog.i18nc("@info:title", "Error"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
message.show()
|
||||
return
|
||||
|
||||
@ -1854,6 +1874,7 @@ class CuraApplication(QtApplication):
|
||||
else:
|
||||
node = CuraSceneNode()
|
||||
node.setMeshData(original_node.getMeshData())
|
||||
node.source_mime_type = original_node.source_mime_type
|
||||
|
||||
# Setting meshdata does not apply scaling.
|
||||
if original_node.getScale() != Vector(1.0, 1.0, 1.0):
|
||||
@ -1939,7 +1960,7 @@ class CuraApplication(QtApplication):
|
||||
try:
|
||||
result = workspace_reader.preRead(file_path, show_dialog=False)
|
||||
return result == WorkspaceReader.PreReadResult.accepted
|
||||
except Exception:
|
||||
except:
|
||||
Logger.logException("e", "Could not check file %s", file_url)
|
||||
return False
|
||||
|
||||
@ -2018,11 +2039,11 @@ class CuraApplication(QtApplication):
|
||||
if not node.isEnabled():
|
||||
continue
|
||||
if (not node.getMeshData() and not node.callDecoration("getLayerData")) and not node.callDecoration("isGroup"):
|
||||
continue # Node that doesnt have a mesh and is not a group.
|
||||
continue # Node that doesn't have a mesh and is not a group.
|
||||
if only_selectable and not node.isSelectable():
|
||||
continue # Only remove nodes that are selectable.
|
||||
if not node.callDecoration("isSliceable") and not node.callDecoration("getLayerData") and not node.callDecoration("isGroup"):
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
|
||||
continue # Grouped nodes don't need resetting as their parent (the group) is reset)
|
||||
nodes.append(node)
|
||||
if nodes:
|
||||
from UM.Operations.GroupedOperation import GroupedOperation
|
||||
|
@ -12,7 +12,7 @@ from cura.CuraApplication import CuraApplication
|
||||
# Since Cura has a few pre-defined "space claims" for the locations of certain components, we've provided some structure
|
||||
# to indicate this.
|
||||
# MainComponent works in the same way the MainComponent of a stage.
|
||||
# the stageMenuComponent returns an item that should be used somehwere in the stage menu. It's up to the active stage
|
||||
# the stageMenuComponent returns an item that should be used somewhere in the stage menu. It's up to the active stage
|
||||
# to actually do something with this.
|
||||
class CuraView(View):
|
||||
def __init__(self, parent = None, use_empty_menu_placeholder: bool = False) -> None:
|
||||
|
@ -59,13 +59,13 @@ class LayerPolygon:
|
||||
self._vertex_count = self._mesh_line_count + numpy.sum(self._types[1:] == self._types[:-1])
|
||||
|
||||
# Buffering the colors shouldn't be necessary as it is not
|
||||
# re-used and can save alot of memory usage.
|
||||
# re-used and can save a lot of memory usage.
|
||||
self._color_map = LayerPolygon.getColorMap()
|
||||
self._colors = self._color_map[self._types] # type: numpy.ndarray
|
||||
|
||||
# When type is used as index returns true if type == LayerPolygon.InfillType or type == LayerPolygon.SkinType or type == LayerPolygon.SupportInfillType
|
||||
# Should be generated in better way, not hardcoded.
|
||||
self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = numpy.bool)
|
||||
self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = bool)
|
||||
|
||||
self._build_cache_line_mesh_mask = None # type: Optional[numpy.ndarray]
|
||||
self._build_cache_needed_points = None # type: Optional[numpy.ndarray]
|
||||
@ -73,18 +73,17 @@ class LayerPolygon:
|
||||
def buildCache(self) -> None:
|
||||
# For the line mesh we do not draw Infill or Jumps. Therefore those lines are filtered out.
|
||||
self._build_cache_line_mesh_mask = numpy.ones(self._jump_mask.shape, dtype = bool)
|
||||
mesh_line_count = numpy.sum(self._build_cache_line_mesh_mask)
|
||||
self._index_begin = 0
|
||||
self._index_end = mesh_line_count
|
||||
self._index_end = cast(int, numpy.sum(self._build_cache_line_mesh_mask))
|
||||
|
||||
self._build_cache_needed_points = numpy.ones((len(self._types), 2), dtype = numpy.bool)
|
||||
self._build_cache_needed_points = numpy.ones((len(self._types), 2), dtype = bool)
|
||||
# Only if the type of line segment changes do we need to add an extra vertex to change colors
|
||||
self._build_cache_needed_points[1:, 0][:, numpy.newaxis] = self._types[1:] != self._types[:-1]
|
||||
# Mark points as unneeded if they are of types we don't want in the line mesh according to the calculated mask
|
||||
numpy.logical_and(self._build_cache_needed_points, self._build_cache_line_mesh_mask, self._build_cache_needed_points )
|
||||
|
||||
self._vertex_begin = 0
|
||||
self._vertex_end = numpy.sum( self._build_cache_needed_points )
|
||||
self._vertex_end = cast(int, numpy.sum(self._build_cache_needed_points))
|
||||
|
||||
def build(self, vertex_offset: int, index_offset: int, vertices: numpy.ndarray, colors: numpy.ndarray, line_dimensions: numpy.ndarray, feedrates: numpy.ndarray, extruders: numpy.ndarray, line_types: numpy.ndarray, indices: numpy.ndarray) -> None:
|
||||
"""Set all the arrays provided by the function caller, representing the LayerPolygon
|
||||
@ -147,7 +146,7 @@ class LayerPolygon:
|
||||
# When the line type changes the index needs to be increased by 2.
|
||||
indices[self._index_begin:self._index_end, :] += numpy.cumsum(needed_points_list[line_mesh_mask.ravel(), 0], dtype = numpy.int32).reshape((-1, 1))
|
||||
# Each line segment goes from it's starting point p to p+1, offset by the vertex index.
|
||||
# The -1 is to compensate for the neccecarily True value of needed_points_list[0,0] which causes an unwanted +1 in cumsum above.
|
||||
# The -1 is to compensate for the necessarily True value of needed_points_list[0,0] which causes an unwanted +1 in cumsum above.
|
||||
indices[self._index_begin:self._index_end, :] += numpy.array([self._vertex_begin - 1, self._vertex_begin])
|
||||
|
||||
self._build_cache_line_mesh_mask = None
|
||||
|
@ -97,8 +97,7 @@ class MachineErrorChecker(QObject):
|
||||
|
||||
def startErrorCheckPropertyChanged(self, key: str, property_name: str) -> None:
|
||||
"""Start the error check for property changed
|
||||
|
||||
this is seperate from the startErrorCheck because it ignores a number property types
|
||||
this is separate from the startErrorCheck because it ignores a number property types
|
||||
|
||||
:param key:
|
||||
:param property_name:
|
||||
|
@ -53,6 +53,9 @@ class ExtrudersModel(ListModel):
|
||||
EnabledRole = Qt.UserRole + 11
|
||||
"""Is the extruder enabled?"""
|
||||
|
||||
MaterialTypeRole = Qt.UserRole + 12
|
||||
"""The type of the material (e.g. PLA, ABS, PETG, etc.)."""
|
||||
|
||||
defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"]
|
||||
"""List of colours to display if there is no material or the material has no known colour. """
|
||||
|
||||
@ -75,6 +78,7 @@ class ExtrudersModel(ListModel):
|
||||
self.addRoleName(self.StackRole, "stack")
|
||||
self.addRoleName(self.MaterialBrandRole, "material_brand")
|
||||
self.addRoleName(self.ColorNameRole, "color_name")
|
||||
self.addRoleName(self.MaterialTypeRole, "material_type")
|
||||
self._update_extruder_timer = QTimer()
|
||||
self._update_extruder_timer.setInterval(100)
|
||||
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
|
||||
"stack": extruder,
|
||||
"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)
|
||||
@ -210,7 +215,7 @@ class ExtrudersModel(ListModel):
|
||||
"id": "",
|
||||
"name": catalog.i18nc("@menuitem", "Not overridden"),
|
||||
"enabled": True,
|
||||
"color": "#ffffff",
|
||||
"color": "transparent",
|
||||
"index": -1,
|
||||
"definition": "",
|
||||
"material": "",
|
||||
@ -218,6 +223,7 @@ class ExtrudersModel(ListModel):
|
||||
"stack": None,
|
||||
"material_brand": "",
|
||||
"color_name": "",
|
||||
"material_type": "",
|
||||
}
|
||||
items.append(item)
|
||||
if self._items != items:
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
@ -34,4 +34,4 @@ def fetchLayerHeight(quality_group: "QualityGroup") -> float:
|
||||
if isinstance(layer_height, SettingFunction):
|
||||
layer_height = layer_height(global_stack)
|
||||
|
||||
return float(layer_height)
|
||||
return round(float(layer_height), 3)
|
||||
|
@ -1,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.
|
||||
|
||||
import copy # To duplicate materials.
|
||||
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot # To allow the preference page proxy to be used from the actual preferences page.
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl
|
||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||
import uuid # To generate new GUIDs for new materials.
|
||||
import zipfile # To export all materials in a .zip archive.
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Logger import Logger
|
||||
@ -20,11 +21,6 @@ if TYPE_CHECKING:
|
||||
catalog = i18nCatalog("cura")
|
||||
|
||||
class MaterialManagementModel(QObject):
|
||||
"""Proxy class to the materials page in the preferences.
|
||||
|
||||
This class handles the actions in that page, such as creating new materials, renaming them, etc.
|
||||
"""
|
||||
|
||||
favoritesChanged = pyqtSignal(str)
|
||||
"""Triggered when a favorite is added or removed.
|
||||
|
||||
@ -79,6 +75,7 @@ class MaterialManagementModel(QObject):
|
||||
|
||||
:param material_node: The material to remove.
|
||||
"""
|
||||
Logger.info(f"Removing material {material_node.container_id}")
|
||||
|
||||
container_registry = CuraContainerRegistry.getInstance()
|
||||
materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file)
|
||||
@ -194,6 +191,7 @@ class MaterialManagementModel(QObject):
|
||||
|
||||
:return: The root material ID of the duplicate material.
|
||||
"""
|
||||
Logger.info(f"Duplicating material {material_node.base_file} to {new_base_id}")
|
||||
return self.duplicateMaterialByBaseFile(material_node.base_file, new_base_id, new_metadata)
|
||||
|
||||
@pyqtSlot(result = str)
|
||||
@ -262,3 +260,40 @@ class MaterialManagementModel(QObject):
|
||||
self.favoritesChanged.emit(material_base_file)
|
||||
except ValueError: # Material was not in the favorites list.
|
||||
Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file))
|
||||
|
||||
@pyqtSlot(result = QUrl)
|
||||
def getPreferredExportAllPath(self) -> QUrl:
|
||||
"""
|
||||
Get the preferred path to export materials to.
|
||||
|
||||
If there is a removable drive, that should be the preferred path. Otherwise it should be the most recent local
|
||||
file path.
|
||||
:return: The preferred path to export all materials to.
|
||||
"""
|
||||
cura_application = cura.CuraApplication.CuraApplication.getInstance()
|
||||
device_manager = cura_application.getOutputDeviceManager()
|
||||
devices = device_manager.getOutputDevices()
|
||||
for device in devices:
|
||||
if device.__class__.__name__ == "RemovableDriveOutputDevice":
|
||||
return QUrl.fromLocalFile(device.getId())
|
||||
else: # No removable drives? Use local path.
|
||||
return cura_application.getDefaultPath("dialog_material_path")
|
||||
|
||||
@pyqtSlot(QUrl)
|
||||
def exportAll(self, file_path: QUrl) -> None:
|
||||
"""
|
||||
Export all materials to a certain file path.
|
||||
:param file_path: The path to export the materials to.
|
||||
"""
|
||||
registry = CuraContainerRegistry.getInstance()
|
||||
|
||||
archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED)
|
||||
for metadata in registry.findInstanceContainersMetadata(type = "material"):
|
||||
if metadata["base_file"] != metadata["id"]: # Only process base files.
|
||||
continue
|
||||
if metadata["id"] == "empty_material": # Don't export the empty material.
|
||||
continue
|
||||
material = registry.findContainers(id = metadata["id"])[0]
|
||||
suffix = registry.getMimeTypeForContainer(type(material)).preferredSuffix
|
||||
filename = metadata["id"] + "." + suffix
|
||||
archive.writestr(filename, material.serialize())
|
||||
|
@ -99,7 +99,7 @@ class QualitySettingsModel(ListModel):
|
||||
if self._selected_position == self.GLOBAL_STACK_POSITION:
|
||||
quality_node = quality_group.node_for_global
|
||||
else:
|
||||
quality_node = quality_group.nodes_for_extruders.get(str(self._selected_position))
|
||||
quality_node = quality_group.nodes_for_extruders.get(self._selected_position)
|
||||
settings_keys = quality_group.getAllKeys()
|
||||
quality_containers = []
|
||||
if quality_node is not None and quality_node.container is not None:
|
||||
@ -114,10 +114,13 @@ class QualitySettingsModel(ListModel):
|
||||
global_container = None if len(global_containers) == 0 else global_containers[0]
|
||||
extruders_containers = {pos: container_registry.findContainers(id = quality_changes_group.metadata_per_extruder[pos]["id"]) for pos in quality_changes_group.metadata_per_extruder}
|
||||
extruders_container = {pos: None if not containers else containers[0] for pos, containers in extruders_containers.items()}
|
||||
quality_changes_metadata = None
|
||||
if self._selected_position == self.GLOBAL_STACK_POSITION and global_container:
|
||||
quality_changes_metadata = global_container.getMetaData()
|
||||
else:
|
||||
quality_changes_metadata = extruders_container.get(str(self._selected_position))
|
||||
extruder = extruders_container.get(self._selected_position)
|
||||
if extruder:
|
||||
quality_changes_metadata = extruder.getMetaData()
|
||||
if quality_changes_metadata is not None: # It can be None if number of extruders are changed during runtime.
|
||||
container = container_registry.findContainers(id = quality_changes_metadata["id"])
|
||||
if container:
|
||||
|
@ -19,6 +19,8 @@ class SettingVisibilityPresetsModel(QObject):
|
||||
onItemsChanged = pyqtSignal()
|
||||
activePresetChanged = pyqtSignal()
|
||||
|
||||
Version = 2
|
||||
|
||||
def __init__(self, preferences: Preferences, parent = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
@ -31,7 +33,7 @@ class SettingVisibilityPresetsModel(QObject):
|
||||
if basic_item is not None:
|
||||
basic_visibile_settings = ";".join(basic_item.settings)
|
||||
else:
|
||||
Logger.log("w", "Unable to find the basic visiblity preset.")
|
||||
Logger.log("w", "Unable to find the basic visibility preset.")
|
||||
basic_visibile_settings = ""
|
||||
|
||||
self._preferences = preferences
|
||||
|
@ -74,5 +74,6 @@ class MultiplyObjectsJob(Job):
|
||||
if not found_solution_for_all:
|
||||
no_full_solution_message = Message(
|
||||
i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"),
|
||||
title = i18n_catalog.i18nc("@info:title", "Placing Object"))
|
||||
title = i18n_catalog.i18nc("@info:title", "Placing Object"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
no_full_solution_message.show()
|
||||
|
@ -1,12 +1,12 @@
|
||||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import random
|
||||
import secrets
|
||||
from hashlib import sha512
|
||||
from base64 import b64encode
|
||||
from typing import Optional, Any, Dict, Tuple
|
||||
|
||||
from typing import Optional
|
||||
import requests
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
@ -48,8 +48,8 @@ class AuthorizationHelpers:
|
||||
}
|
||||
try:
|
||||
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore
|
||||
except requests.exceptions.ConnectionError:
|
||||
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server")
|
||||
except requests.exceptions.ConnectionError as connection_error:
|
||||
return AuthenticationResponse(success = False, err_message = f"Unable to connect to remote server: {connection_error}")
|
||||
|
||||
def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse":
|
||||
"""Request the access token from the authorization server using a refresh token.
|
||||
@ -58,7 +58,7 @@ class AuthorizationHelpers:
|
||||
: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 = {
|
||||
"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 "",
|
||||
@ -110,10 +110,12 @@ class AuthorizationHelpers:
|
||||
"""
|
||||
|
||||
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)
|
||||
})
|
||||
except requests.exceptions.ConnectionError:
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||
# Connection was suddenly dropped. Nothing we can do about that.
|
||||
Logger.logException("w", "Something failed while attempting to parse the JWT token")
|
||||
return None
|
||||
@ -137,11 +139,11 @@ class AuthorizationHelpers:
|
||||
def generateVerificationCode(code_length: int = 32) -> str:
|
||||
"""Generate a verification code of arbitrary length.
|
||||
|
||||
:param code_length:: How long should the code be? This should never be lower than 16, but it's probably
|
||||
:param code_length:: How long should the code be in bytes? This should never be lower than 16, but it's probably
|
||||
better to leave it at 32
|
||||
"""
|
||||
|
||||
return "".join(random.choice("0123456789ABCDEF") for i in range(code_length))
|
||||
return secrets.token_hex(code_length)
|
||||
|
||||
@staticmethod
|
||||
def generateVerificationCodeChallenge(verification_code: str) -> str:
|
||||
|
@ -24,7 +24,7 @@ if TYPE_CHECKING:
|
||||
from cura.OAuth2.Models import UserProfile, OAuth2Settings
|
||||
from UM.Preferences import Preferences
|
||||
|
||||
MYCLOUD_LOGOFF_URL = "https://mycloud.ultimaker.com/logoff"
|
||||
MYCLOUD_LOGOFF_URL = "https://account.ultimaker.com/logoff?utm_source=cura&utm_medium=software&utm_campaign=change-account-before-adding-printers"
|
||||
|
||||
class AuthorizationService:
|
||||
"""The authorization service is responsible for handling the login flow, storing user credentials and providing
|
||||
@ -113,12 +113,14 @@ class AuthorizationService:
|
||||
# The token could not be refreshed using the refresh token. We should login again.
|
||||
return None
|
||||
# Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been deleted
|
||||
# from the server already.
|
||||
self._storeAuthData(self._auth_data)
|
||||
# from the server already. Do not store the auth_data if we could not get new auth_data (eg due to a
|
||||
# network error), since this would cause an infinite loop trying to get new auth-data
|
||||
if self._auth_data.success:
|
||||
self._storeAuthData(self._auth_data)
|
||||
return self._auth_helpers.parseJWT(self._auth_data.access_token)
|
||||
|
||||
def getAccessToken(self) -> Optional[str]:
|
||||
"""Get the access token as provided by the repsonse data."""
|
||||
"""Get the access token as provided by the response data."""
|
||||
|
||||
if self._auth_data is None:
|
||||
Logger.log("d", "No auth data to retrieve the access_token from")
|
||||
@ -184,8 +186,10 @@ class AuthorizationService:
|
||||
self._server.start(verification_code, state)
|
||||
except OSError:
|
||||
Logger.logException("w", "Unable to create authorization request server")
|
||||
Message(i18n_catalog.i18nc("@info", "Unable to start a new sign in process. Check if another sign in attempt is still active."),
|
||||
title=i18n_catalog.i18nc("@info:title", "Warning")).show()
|
||||
Message(i18n_catalog.i18nc("@info",
|
||||
"Unable to start a new sign in process. Check if another sign in attempt is still active."),
|
||||
title=i18n_catalog.i18nc("@info:title", "Warning"),
|
||||
message_type = Message.MessageType.WARNING).show()
|
||||
return
|
||||
|
||||
auth_url = self._generate_auth_url(query_parameters_dict, force_browser_logout)
|
||||
@ -205,25 +209,27 @@ class AuthorizationService:
|
||||
link to force the a browser logout from mycloud.ultimaker.com
|
||||
:return: The authentication URL, properly formatted and encoded
|
||||
"""
|
||||
auth_url = "{}?{}".format(self._auth_url, urlencode(query_parameters_dict))
|
||||
auth_url = f"{self._auth_url}?{urlencode(query_parameters_dict)}"
|
||||
if force_browser_logout:
|
||||
# The url after '?next=' should be urlencoded
|
||||
auth_url = "{}?next={}".format(MYCLOUD_LOGOFF_URL, quote_plus(auth_url))
|
||||
connecting_char = "&" if "?" in MYCLOUD_LOGOFF_URL else "?"
|
||||
# The url after 'next=' should be urlencoded
|
||||
auth_url = f"{MYCLOUD_LOGOFF_URL}{connecting_char}next={quote_plus(auth_url)}"
|
||||
return auth_url
|
||||
|
||||
def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:
|
||||
"""Callback method for the authentication flow."""
|
||||
|
||||
if auth_response.success:
|
||||
Logger.log("d", "Got callback from Authorization state. The user should now be logged in!")
|
||||
self._storeAuthData(auth_response)
|
||||
self.onAuthStateChanged.emit(logged_in = True)
|
||||
else:
|
||||
Logger.log("d", "Got callback from Authorization state. Something went wrong: [%s]", auth_response.err_message)
|
||||
self.onAuthenticationError.emit(logged_in = False, error_message = auth_response.err_message)
|
||||
self._server.stop() # Stop the web server at all times.
|
||||
|
||||
def loadAuthDataFromPreferences(self) -> None:
|
||||
"""Load authentication data from preferences."""
|
||||
|
||||
Logger.log("d", "Attempting to load the auth data from preferences.")
|
||||
if self._preferences is None:
|
||||
Logger.log("e", "Unable to load authentication data, since no preference has been set!")
|
||||
return
|
||||
@ -235,11 +241,16 @@ class AuthorizationService:
|
||||
user_profile = self.getUserProfile()
|
||||
if user_profile is not None:
|
||||
self.onAuthStateChanged.emit(logged_in = True)
|
||||
Logger.log("d", "Auth data was successfully loaded")
|
||||
else:
|
||||
if self._unable_to_get_data_message is not None:
|
||||
self._unable_to_get_data_message.hide()
|
||||
|
||||
self._unable_to_get_data_message = Message(i18n_catalog.i18nc("@info", "Unable to reach the Ultimaker account server."), title = i18n_catalog.i18nc("@info:title", "Warning"))
|
||||
self._unable_to_get_data_message = Message(i18n_catalog.i18nc("@info",
|
||||
"Unable to reach the Ultimaker account server."),
|
||||
title = i18n_catalog.i18nc("@info:title", "Warning"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
Logger.log("w", "Unable to load auth data from preferences")
|
||||
self._unable_to_get_data_message.show()
|
||||
except (ValueError, TypeError):
|
||||
Logger.logException("w", "Could not load auth data from preferences")
|
||||
@ -247,7 +258,7 @@ class AuthorizationService:
|
||||
def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None:
|
||||
"""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:
|
||||
Logger.log("e", "Unable to save authentication data, since no preference has been set!")
|
||||
return
|
||||
@ -255,10 +266,10 @@ class AuthorizationService:
|
||||
self._auth_data = auth_data
|
||||
if auth_data:
|
||||
self._user_profile = self.getUserProfile()
|
||||
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data)))
|
||||
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(auth_data.dump()))
|
||||
else:
|
||||
Logger.log("d", "Clearing the user profile")
|
||||
self._user_profile = None
|
||||
self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)
|
||||
|
||||
self.accessTokenChanged.emit()
|
||||
|
||||
|
92
cura/OAuth2/KeyringAttribute.py
Normal file
@ -0,0 +1,92 @@
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Type, TYPE_CHECKING, Optional, List
|
||||
|
||||
import keyring
|
||||
from keyring.backend import KeyringBackend
|
||||
from keyring.errors import NoKeyringError, PasswordSetError, KeyringLocked
|
||||
|
||||
from UM.Logger import Logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.OAuth2.Models import BaseModel
|
||||
|
||||
# Need to do some extra workarounds on windows:
|
||||
import sys
|
||||
from UM.Platform import Platform
|
||||
if Platform.isWindows():
|
||||
if hasattr(sys, "frozen"):
|
||||
import win32timezone
|
||||
from keyring.backends.Windows import WinVaultKeyring
|
||||
keyring.set_keyring(WinVaultKeyring())
|
||||
if Platform.isOSX():
|
||||
from keyring.backends.macOS import Keyring
|
||||
keyring.set_keyring(Keyring())
|
||||
if Platform.isLinux():
|
||||
# We do not support the keyring on Linux, so make sure no Keyring backend is loaded, even if there is a system one.
|
||||
from keyring.backends.fail import Keyring as NoKeyringBackend
|
||||
keyring.set_keyring(NoKeyringBackend())
|
||||
|
||||
# Even if errors happen, we don't want this stored locally:
|
||||
DONT_EVER_STORE_LOCALLY: List[str] = ["refresh_token"]
|
||||
|
||||
|
||||
class KeyringAttribute:
|
||||
"""
|
||||
Descriptor for attributes that need to be stored in the keyring. With Fallback behaviour to the preference cfg file
|
||||
"""
|
||||
def __get__(self, instance: "BaseModel", owner: type) -> Optional[str]:
|
||||
if self._store_secure: # type: ignore
|
||||
try:
|
||||
value = keyring.get_password("cura", self._keyring_name)
|
||||
return value if value != "" else None
|
||||
except NoKeyringError:
|
||||
self._store_secure = False
|
||||
Logger.logException("w", "No keyring backend present")
|
||||
return getattr(instance, self._name)
|
||||
except KeyringLocked:
|
||||
self._store_secure = False
|
||||
Logger.log("i", "Access to the keyring was denied.")
|
||||
return getattr(instance, self._name)
|
||||
except UnicodeDecodeError:
|
||||
self._store_secure = False
|
||||
Logger.log("w", "The password retrieved from the keyring cannot be used because it contains characters that cannot be decoded.")
|
||||
return getattr(instance, self._name)
|
||||
else:
|
||||
return getattr(instance, self._name)
|
||||
|
||||
def __set__(self, instance: "BaseModel", value: Optional[str]):
|
||||
if self._store_secure:
|
||||
setattr(instance, self._name, None)
|
||||
if value is not None:
|
||||
try:
|
||||
keyring.set_password("cura", self._keyring_name, value)
|
||||
except (PasswordSetError, KeyringLocked):
|
||||
self._store_secure = False
|
||||
if self._name not in DONT_EVER_STORE_LOCALLY:
|
||||
setattr(instance, self._name, value)
|
||||
Logger.logException("w", "Keyring access denied")
|
||||
except NoKeyringError:
|
||||
self._store_secure = False
|
||||
if self._name not in DONT_EVER_STORE_LOCALLY:
|
||||
setattr(instance, self._name, value)
|
||||
Logger.logException("w", "No keyring backend present")
|
||||
except BaseException as e:
|
||||
# A BaseException can occur in Windows when the keyring attempts to write a token longer than 1024
|
||||
# characters in the Windows Credentials Manager.
|
||||
self._store_secure = False
|
||||
if self._name not in DONT_EVER_STORE_LOCALLY:
|
||||
setattr(instance, self._name, value)
|
||||
Logger.log("w", "Keyring failed: {}".format(e))
|
||||
else:
|
||||
setattr(instance, self._name, value)
|
||||
|
||||
def __set_name__(self, owner: type, name: str):
|
||||
self._name = "_{}".format(name)
|
||||
self._keyring_name = name
|
||||
self._store_secure = False
|
||||
try:
|
||||
self._store_secure = KeyringBackend.viable
|
||||
except NoKeyringError:
|
||||
Logger.logException("w", "Could not use keyring")
|
||||
setattr(owner, self._name, None)
|
@ -54,6 +54,7 @@ class LocalAuthorizationServer:
|
||||
if self._web_server:
|
||||
# If the server is already running (because of a previously aborted auth flow), we don't have to start it.
|
||||
# We still inject the new verification code though.
|
||||
Logger.log("d", "Auth web server was already running. Updating the verification code")
|
||||
self._web_server.setVerificationCode(verification_code)
|
||||
return
|
||||
|
||||
@ -85,6 +86,7 @@ class LocalAuthorizationServer:
|
||||
except OSError:
|
||||
# OS error can happen if the socket was already closed. We really don't care about that case.
|
||||
pass
|
||||
Logger.log("d", "Local oauth2 web server was shut down")
|
||||
self._web_server = None
|
||||
self._web_server_thread = None
|
||||
|
||||
@ -96,12 +98,13 @@ class LocalAuthorizationServer:
|
||||
|
||||
:return: None
|
||||
"""
|
||||
Logger.log("d", "Local web server for authorization has started")
|
||||
if self._web_server:
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
self._web_server.serve_forever()
|
||||
except OSError as e:
|
||||
Logger.warning(str(e))
|
||||
except OSError:
|
||||
Logger.logException("w", "An exception happened while serving the auth server")
|
||||
else:
|
||||
# Leave the default behavior in non-windows platforms
|
||||
self._web_server.serve_forever()
|
||||
|
@ -1,6 +1,8 @@
|
||||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing import Optional, Dict, Any, List, Union
|
||||
from copy import deepcopy
|
||||
from cura.OAuth2.KeyringAttribute import KeyringAttribute
|
||||
|
||||
|
||||
class BaseModel:
|
||||
@ -37,12 +39,29 @@ class AuthenticationResponse(BaseModel):
|
||||
# Data comes from the token response with success flag and error message added.
|
||||
success = True # type: bool
|
||||
token_type = None # type: Optional[str]
|
||||
access_token = None # type: Optional[str]
|
||||
refresh_token = None # type: Optional[str]
|
||||
expires_in = None # type: Optional[str]
|
||||
scope = None # type: Optional[str]
|
||||
err_message = None # type: Optional[str]
|
||||
received_at = None # type: Optional[str]
|
||||
access_token = KeyringAttribute()
|
||||
refresh_token = KeyringAttribute()
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self.access_token = kwargs.pop("access_token", None)
|
||||
self.refresh_token = kwargs.pop("refresh_token", None)
|
||||
super(AuthenticationResponse, self).__init__(**kwargs)
|
||||
|
||||
def dump(self) -> Dict[str, Union[bool, Optional[str]]]:
|
||||
"""
|
||||
Dumps the dictionary of Authentication attributes. KeyringAttributes are transformed to public attributes
|
||||
If the keyring was used, these will have a None value, otherwise they will have the secret value
|
||||
|
||||
:return: Dictionary of Authentication attributes
|
||||
"""
|
||||
dumped = deepcopy(vars(self))
|
||||
dumped["access_token"] = dumped.pop("_access_token")
|
||||
dumped["refresh_token"] = dumped.pop("_refresh_token")
|
||||
return dumped
|
||||
|
||||
|
||||
class ResponseStatus(BaseModel):
|
||||
|
@ -79,7 +79,7 @@ class PickingPass(RenderPass):
|
||||
return -1
|
||||
|
||||
distance = output.pixel(px, py) # distance in micron, from in r, g & b channels
|
||||
distance = (distance & 0x00ffffff) / 1000. # drop the alpha channel and covert to mm
|
||||
distance = (distance & 0x00ffffff) / 1000. # drop the alpha channel and convert to mm
|
||||
return distance
|
||||
|
||||
def getPickedPosition(self, x: int, y: int) -> Vector:
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from typing import Optional, TYPE_CHECKING, cast, List
|
||||
@ -74,6 +74,7 @@ class PreviewPass(RenderPass):
|
||||
self._shader.setUniformValue("u_faceId", -1) # Don't render any selected faces in the preview.
|
||||
else:
|
||||
Logger.error("Unable to compile shader program: overhang.shader")
|
||||
return
|
||||
|
||||
if not self._non_printing_shader:
|
||||
self._non_printing_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader"))
|
||||
|
@ -49,7 +49,7 @@ class FirmwareUpdater(QObject):
|
||||
raise NotImplementedError("_updateFirmware needs to be implemented")
|
||||
|
||||
def _cleanupAfterUpdate(self) -> None:
|
||||
"""Cleanup after a succesful update"""
|
||||
"""Cleanup after a successful update"""
|
||||
|
||||
# Clean up for next attempt.
|
||||
self._update_firmware_thread = Thread(target=self._updateFirmware, daemon=True, name = "FirmwareUpdateThread")
|
||||
|
@ -414,6 +414,6 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
|
||||
@pyqtProperty(str, constant = True)
|
||||
def ipAddress(self) -> str:
|
||||
"""IP adress of this printer"""
|
||||
"""IP address of this printer"""
|
||||
|
||||
return self._address
|
||||
|
@ -119,21 +119,23 @@ class CuraSceneNode(SceneNode):
|
||||
self._aabb = None
|
||||
if self._mesh_data:
|
||||
self._aabb = self._mesh_data.getExtents(self.getWorldTransformation(copy = False))
|
||||
else: # If there is no mesh_data, use a bounding box that encompasses the local (0,0,0)
|
||||
position = self.getWorldPosition()
|
||||
self._aabb = AxisAlignedBox(minimum = position, maximum = position)
|
||||
|
||||
for child in self.getAllChildren():
|
||||
if child.callDecoration("isNonPrintingMesh"):
|
||||
# Non-printing-meshes inside a group should not affect push apart or drop to build plate
|
||||
continue
|
||||
if not child.getMeshData():
|
||||
# Nodes without mesh data should not affect bounding boxes of their parents.
|
||||
child_bb = child.getBoundingBox()
|
||||
if child_bb is None or child_bb.minimum == child_bb.maximum:
|
||||
# Child had a degenerate bounding box, such as an empty group. Don't count it along.
|
||||
continue
|
||||
if self._aabb is None:
|
||||
self._aabb = child.getBoundingBox()
|
||||
self._aabb = child_bb
|
||||
else:
|
||||
self._aabb = self._aabb + child.getBoundingBox()
|
||||
self._aabb = self._aabb + child_bb
|
||||
|
||||
if self._aabb is None: # No children that should be included? Just use your own position then, but it's an invalid AABB.
|
||||
position = self.getWorldPosition()
|
||||
self._aabb = AxisAlignedBox(minimum = position, maximum = position)
|
||||
|
||||
def __deepcopy__(self, memo: Dict[int, object]) -> "CuraSceneNode":
|
||||
"""Taken from SceneNode, but replaced SceneNode with CuraSceneNode"""
|
||||
@ -142,6 +144,7 @@ class CuraSceneNode(SceneNode):
|
||||
copy.setTransformation(self.getLocalTransformation(copy= False))
|
||||
copy.setMeshData(self._mesh_data)
|
||||
copy.setVisible(cast(bool, deepcopy(self._visible, memo)))
|
||||
copy.source_mime_type = cast(str, deepcopy(self.source_mime_type, memo))
|
||||
copy._selectable = cast(bool, deepcopy(self._selectable, memo))
|
||||
copy._name = cast(str, deepcopy(self._name, memo))
|
||||
for decorator in self._decorators:
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import os
|
||||
@ -241,6 +241,7 @@ class ContainerManager(QObject):
|
||||
file_url = file_url_or_string.toLocalFile()
|
||||
else:
|
||||
file_url = file_url_or_string
|
||||
Logger.info(f"Importing material from {file_url}")
|
||||
|
||||
if not file_url or not os.path.exists(file_url):
|
||||
return {"status": "error", "message": "Invalid path"}
|
||||
@ -318,7 +319,7 @@ class ContainerManager(QObject):
|
||||
stack.qualityChanges = quality_changes
|
||||
|
||||
if not quality_changes or container_registry.isReadOnly(quality_changes.getId()):
|
||||
Logger.log("e", "Could not update quality of a nonexistant or read only quality profile in stack %s", stack.getId())
|
||||
Logger.log("e", "Could not update quality of a nonexistent or read only quality profile in stack %s", stack.getId())
|
||||
continue
|
||||
|
||||
self._performMerge(quality_changes, stack.getTop())
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import os
|
||||
@ -32,6 +32,10 @@ from cura.Machines.ContainerTree import ContainerTree
|
||||
from cura.ReaderWriters.ProfileReader import NoProfileException, ProfileReader
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
from .DatabaseHandlers.IntentDatabaseHandler import IntentDatabaseHandler
|
||||
from .DatabaseHandlers.QualityDatabaseHandler import QualityDatabaseHandler
|
||||
from .DatabaseHandlers.VariantDatabaseHandler import VariantDatabaseHandler
|
||||
|
||||
catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
@ -44,6 +48,10 @@ class CuraContainerRegistry(ContainerRegistry):
|
||||
# is added, we check to see if an extruder stack needs to be added.
|
||||
self.containerAdded.connect(self._onContainerAdded)
|
||||
|
||||
self._database_handlers["variant"] = VariantDatabaseHandler()
|
||||
self._database_handlers["quality"] = QualityDatabaseHandler()
|
||||
self._database_handlers["intent"] = IntentDatabaseHandler()
|
||||
|
||||
@override(ContainerRegistry)
|
||||
def addContainer(self, container: ContainerInterface) -> bool:
|
||||
"""Overridden from ContainerRegistry
|
||||
@ -141,20 +149,29 @@ class CuraContainerRegistry(ContainerRegistry):
|
||||
success = profile_writer.write(file_name, container_list)
|
||||
except Exception as e:
|
||||
Logger.log("e", "Failed to export profile to %s: %s", file_name, str(e))
|
||||
m = Message(catalog.i18nc("@info:status Don't translate the XML tags <filename> or <message>!", "Failed to export profile to <filename>{0}</filename>: <message>{1}</message>", file_name, str(e)),
|
||||
m = Message(catalog.i18nc("@info:status Don't translate the XML tags <filename> or <message>!",
|
||||
"Failed to export profile to <filename>{0}</filename>: <message>{1}</message>",
|
||||
file_name, str(e)),
|
||||
lifetime = 0,
|
||||
title = catalog.i18nc("@info:title", "Error"))
|
||||
title = catalog.i18nc("@info:title", "Error"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
m.show()
|
||||
return False
|
||||
if not success:
|
||||
Logger.log("w", "Failed to export profile to %s: Writer plugin reported failure.", file_name)
|
||||
m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Failed to export profile to <filename>{0}</filename>: Writer plugin reported failure.", file_name),
|
||||
m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!",
|
||||
"Failed to export profile to <filename>{0}</filename>: Writer plugin reported failure.",
|
||||
file_name),
|
||||
lifetime = 0,
|
||||
title = catalog.i18nc("@info:title", "Error"))
|
||||
title = catalog.i18nc("@info:title", "Error"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
m.show()
|
||||
return False
|
||||
m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Exported profile to <filename>{0}</filename>", file_name),
|
||||
title = catalog.i18nc("@info:title", "Export succeeded"))
|
||||
m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!",
|
||||
"Exported profile to <filename>{0}</filename>",
|
||||
file_name),
|
||||
title = catalog.i18nc("@info:title", "Export succeeded"),
|
||||
message_type = Message.MessageType.POSITIVE)
|
||||
m.show()
|
||||
return True
|
||||
|
||||
@ -381,9 +398,10 @@ class CuraContainerRegistry(ContainerRegistry):
|
||||
if profile_count > 1:
|
||||
continue
|
||||
# Only one profile found, this should not ever be the case, so that profile needs to be removed!
|
||||
Logger.log("d", "Found an invalid quality_changes profile with the name %s. Going to remove that now", profile_name)
|
||||
invalid_quality_changes = ContainerRegistry.getInstance().findContainersMetadata(name=profile_name)
|
||||
self.removeContainer(invalid_quality_changes[0]["id"])
|
||||
if invalid_quality_changes:
|
||||
Logger.log("d", "Found an invalid quality_changes profile with the name %s. Going to remove that now", profile_name)
|
||||
self.removeContainer(invalid_quality_changes[0]["id"])
|
||||
|
||||
@override(ContainerRegistry)
|
||||
def _isMetadataValid(self, metadata: Optional[Dict[str, Any]]) -> bool:
|
||||
|
@ -66,7 +66,7 @@ class CuraStackBuilder:
|
||||
Logger.logException("e", "Failed to create an extruder stack for position {pos}: {err}".format(pos = position, err = str(e)))
|
||||
return None
|
||||
|
||||
# If given, set the machine_extruder_count when creating the machine, or else the extruderList used bellow will
|
||||
# If given, set the machine_extruder_count when creating the machine, or else the extruderList used below will
|
||||
# not return the correct extruder list (since by default, the machine_extruder_count is 1) in machines with
|
||||
# settable number of extruders.
|
||||
if machine_extruder_count and 0 <= machine_extruder_count <= len(extruder_dict):
|
||||
|
25
cura/Settings/DatabaseHandlers/IntentDatabaseHandler.py
Normal file
@ -0,0 +1,25 @@
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from UM.Settings.SQLQueryFactory import SQLQueryFactory
|
||||
from UM.Settings.DatabaseContainerMetadataController import DatabaseMetadataContainerController
|
||||
from UM.Settings.InstanceContainer import InstanceContainer
|
||||
|
||||
|
||||
class IntentDatabaseHandler(DatabaseMetadataContainerController):
|
||||
"""The Database handler for Intent containers"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(SQLQueryFactory(table = "intent",
|
||||
fields = {
|
||||
"id": "text",
|
||||
"name": "text",
|
||||
"quality_type": "text",
|
||||
"intent_category": "text",
|
||||
"variant": "text",
|
||||
"definition": "text",
|
||||
"material": "text",
|
||||
"version": "text",
|
||||
"setting_version": "text"
|
||||
}))
|
||||
self._container_type = InstanceContainer
|
38
cura/Settings/DatabaseHandlers/QualityDatabaseHandler.py
Normal file
@ -0,0 +1,38 @@
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from UM.Settings.SQLQueryFactory import SQLQueryFactory, metadata_type
|
||||
from UM.Settings.DatabaseContainerMetadataController import DatabaseMetadataContainerController
|
||||
from UM.Settings.InstanceContainer import InstanceContainer
|
||||
|
||||
|
||||
class QualityDatabaseHandler(DatabaseMetadataContainerController):
|
||||
"""The Database handler for Quality containers"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(SQLQueryFactory(table = "quality",
|
||||
fields = {
|
||||
"id": "text",
|
||||
"name": "text",
|
||||
"quality_type": "text",
|
||||
"material": "text",
|
||||
"variant": "text",
|
||||
"global_quality": "bool",
|
||||
"definition": "text",
|
||||
"version": "text",
|
||||
"setting_version": "text"
|
||||
}))
|
||||
self._container_type = InstanceContainer
|
||||
|
||||
def groomMetadata(self, metadata: metadata_type) -> metadata_type:
|
||||
"""
|
||||
Ensures that the metadata is in the order of the field keys and has the right size.
|
||||
if the metadata doesn't contains a key which is stored in the DB it will add it as
|
||||
an empty string. Key, value pairs that are not stored in the DB are dropped.
|
||||
If the `global_quality` isn't set it well default to 'False'
|
||||
|
||||
:param metadata: The container metadata
|
||||
"""
|
||||
if "global_quality" not in metadata:
|
||||
metadata["global_quality"] = "False"
|
||||
return super().groomMetadata(metadata)
|
22
cura/Settings/DatabaseHandlers/VariantDatabaseHandler.py
Normal file
@ -0,0 +1,22 @@
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from UM.Settings.SQLQueryFactory import SQLQueryFactory
|
||||
from UM.Settings.DatabaseContainerMetadataController import DatabaseMetadataContainerController
|
||||
from UM.Settings.InstanceContainer import InstanceContainer
|
||||
|
||||
|
||||
class VariantDatabaseHandler(DatabaseMetadataContainerController):
|
||||
"""The Database handler for Variant containers"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(SQLQueryFactory(table = "variant",
|
||||
fields = {
|
||||
"id": "text",
|
||||
"name": "text",
|
||||
"hardware_type": "text",
|
||||
"definition": "text",
|
||||
"version": "text",
|
||||
"setting_version": "text"
|
||||
}))
|
||||
self._container_type = InstanceContainer
|
0
cura/Settings/DatabaseHandlers/__init__.py
Normal file
@ -86,6 +86,14 @@ class GlobalStack(CuraContainerStack):
|
||||
def supportsNetworkConnection(self):
|
||||
return self.getMetaDataEntry("supports_network_connection", False)
|
||||
|
||||
@pyqtProperty(bool, constant = True)
|
||||
def supportsMaterialExport(self):
|
||||
"""
|
||||
Whether the printer supports Cura's export format of material profiles.
|
||||
:return: ``True`` if it supports it, or ``False`` if not.
|
||||
"""
|
||||
return self.getMetaDataEntry("supports_material_export", False)
|
||||
|
||||
@classmethod
|
||||
def getLoadingPriority(cls) -> int:
|
||||
return 2
|
||||
|
@ -627,7 +627,7 @@ class MachineManager(QObject):
|
||||
return ""
|
||||
return global_container_stack.getIntentCategory()
|
||||
|
||||
# Provies a list of extruder positions that have a different intent from the active one.
|
||||
# Provides a list of extruder positions that have a different intent from the active one.
|
||||
@pyqtProperty("QStringList", notify=activeIntentChanged)
|
||||
def extruderPositionsWithNonActiveIntent(self):
|
||||
global_container_stack = self._application.getGlobalContainerStack()
|
||||
@ -853,7 +853,8 @@ class MachineManager(QObject):
|
||||
self._global_container_stack.userChanges.setProperty(setting_key, "value", self._default_extruder_position)
|
||||
if add_user_changes:
|
||||
caution_message = Message(
|
||||
catalog.i18nc("@info:message Followed by a list of settings.", "Settings have been changed to match the current availability of extruders:") + " [{settings_list}]".format(settings_list = ", ".join(add_user_changes)),
|
||||
catalog.i18nc("@info:message Followed by a list of settings.",
|
||||
"Settings have been changed to match the current availability of extruders:") + " [{settings_list}]".format(settings_list = ", ".join(add_user_changes)),
|
||||
lifetime = 0,
|
||||
title = catalog.i18nc("@info:title", "Settings updated"))
|
||||
caution_message.show()
|
||||
|
@ -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.
|
||||
|
||||
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.
|
||||
try:
|
||||
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.
|
||||
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
|
||||
|
@ -18,6 +18,8 @@ class SingleInstance:
|
||||
|
||||
self._single_instance_server = None
|
||||
|
||||
self._application.getPreferences().addPreference("cura/single_instance_clear_before_load", True)
|
||||
|
||||
# Starts a client that checks for a single instance server and sends the files that need to opened if the server
|
||||
# exists. Returns True if the single instance server is found, otherwise False.
|
||||
def startClient(self) -> bool:
|
||||
@ -42,8 +44,9 @@ class SingleInstance:
|
||||
# "command" field is required and holds the name of the command to execute.
|
||||
# Other fields depend on the command.
|
||||
|
||||
payload = {"command": "clear-all"}
|
||||
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii"))
|
||||
if self._application.getPreferences().getValue("cura/single_instance_clear_before_load"):
|
||||
payload = {"command": "clear-all"}
|
||||
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii"))
|
||||
|
||||
payload = {"command": "focus"}
|
||||
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii"))
|
||||
@ -68,7 +71,7 @@ class SingleInstance:
|
||||
Logger.log("e", "Single instance server was not created.")
|
||||
|
||||
def _onClientConnected(self) -> None:
|
||||
Logger.log("i", "New connection recevied on our single-instance server")
|
||||
Logger.log("i", "New connection received on our single-instance server")
|
||||
connection = None #type: Optional[QLocalSocket]
|
||||
if self._single_instance_server:
|
||||
connection = self._single_instance_server.nextPendingConnection()
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import numpy
|
||||
|
||||
@ -25,8 +25,8 @@ class Snapshot:
|
||||
pixels = numpy.frombuffer(pixel_array, dtype=numpy.uint8).reshape([height, width, 4])
|
||||
# Find indices of non zero pixels
|
||||
nonzero_pixels = numpy.nonzero(pixels)
|
||||
min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1)
|
||||
max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1)
|
||||
min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1) # type: ignore
|
||||
max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1) # type: ignore
|
||||
|
||||
return min_x, max_x, min_y, max_y
|
||||
|
||||
@ -42,8 +42,8 @@ class Snapshot:
|
||||
"""
|
||||
|
||||
scene = Application.getInstance().getController().getScene()
|
||||
active_camera = scene.getActiveCamera()
|
||||
render_width, render_height = active_camera.getWindowSize()
|
||||
active_camera = scene.getActiveCamera() or scene.findCamera("3d")
|
||||
render_width, render_height = (width, height) if active_camera is None else active_camera.getWindowSize()
|
||||
render_width = int(render_width)
|
||||
render_height = int(render_height)
|
||||
preview_pass = PreviewPass(render_width, render_height)
|
||||
@ -93,7 +93,7 @@ class Snapshot:
|
||||
pixel_output = preview_pass.getOutput()
|
||||
try:
|
||||
min_x, max_x, min_y, max_y = Snapshot.getImageBoundaries(pixel_output)
|
||||
except ValueError:
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
size = max((max_x - min_x) / render_width, (max_y - min_y) / render_height)
|
||||
|
@ -56,8 +56,8 @@ class OnExitCallbackManager:
|
||||
self._application.callLater(self._application.closeApplication)
|
||||
|
||||
# This is the callback function which an on-exit callback should call when it finishes, it should provide the
|
||||
# "should_proceed" flag indicating whether this check has "passed", or in other words, whether quiting the
|
||||
# application should be blocked. If the last on-exit callback doesn't block the quiting, it will call the next
|
||||
# "should_proceed" flag indicating whether this check has "passed", or in other words, whether quitting the
|
||||
# application should be blocked. If the last on-exit callback doesn't block the quitting, it will call the next
|
||||
# registered on-exit callback if available.
|
||||
def onCurrentCallbackFinished(self, should_proceed: bool = True) -> None:
|
||||
if not should_proceed:
|
||||
|
@ -90,7 +90,7 @@ class ObjectsModel(ListModel):
|
||||
|
||||
parent = node.getParent()
|
||||
if parent and parent.callDecoration("isGroup"):
|
||||
return False # Grouped nodes don't need resetting as their parent (the group) is resetted)
|
||||
return False # Grouped nodes don't need resetting as their parent (the group) is reset)
|
||||
|
||||
node_build_plate_number = node.callDecoration("getBuildPlateNumber")
|
||||
if Application.getInstance().getPreferences().getValue("view/filter_current_build_plate") and node_build_plate_number != self._build_plate_number:
|
||||
@ -132,9 +132,26 @@ class ObjectsModel(ListModel):
|
||||
|
||||
is_group = bool(node.callDecoration("isGroup"))
|
||||
|
||||
name_handled_as_group = False
|
||||
force_rename = False
|
||||
if not is_group:
|
||||
# Handle names for individual nodes
|
||||
if is_group:
|
||||
# Handle names for grouped nodes
|
||||
original_name = self._group_name_prefix
|
||||
|
||||
current_name = node.getName()
|
||||
if current_name.startswith(self._group_name_prefix):
|
||||
# This group has a standard group name, but we may need to renumber it
|
||||
name_index = int(current_name.split("#")[-1])
|
||||
name_handled_as_group = True
|
||||
elif not current_name:
|
||||
# Force rename this group because this node has not been named as a group yet, probably because
|
||||
# it's a newly created group.
|
||||
name_index = 0
|
||||
force_rename = True
|
||||
name_handled_as_group = True
|
||||
|
||||
if not is_group or not name_handled_as_group:
|
||||
# Handle names for individual nodes or groups that already have a non-group name
|
||||
name = node.getName()
|
||||
|
||||
name_match = self._naming_regex.fullmatch(name)
|
||||
@ -144,18 +161,6 @@ class ObjectsModel(ListModel):
|
||||
else:
|
||||
original_name = name_match.groups()[0]
|
||||
name_index = int(name_match.groups()[1])
|
||||
else:
|
||||
# Handle names for grouped nodes
|
||||
original_name = self._group_name_prefix
|
||||
|
||||
current_name = node.getName()
|
||||
if current_name.startswith(self._group_name_prefix):
|
||||
name_index = int(current_name.split("#")[-1])
|
||||
else:
|
||||
# Force rename this group because this node has not been named as a group yet, probably because
|
||||
# it's a newly created group.
|
||||
name_index = 0
|
||||
force_rename = True
|
||||
|
||||
if original_name not in name_to_node_info_dict:
|
||||
# Keep track of 2 things:
|
||||
|
@ -4,7 +4,6 @@
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import unicodedata
|
||||
from typing import Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot, QTimer
|
||||
@ -14,6 +13,8 @@ from UM.Qt.Duration import Duration
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.MimeTypeDatabase import MimeTypeDatabase, MimeTypeNotFoundError
|
||||
from UM.OutputDevice.OutputDevice import OutputDevice
|
||||
from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
@ -69,6 +70,7 @@ class PrintInformation(QObject):
|
||||
self._application.globalContainerStackChanged.connect(self.setToZeroPrintInformation)
|
||||
self._application.fileLoaded.connect(self.setBaseName)
|
||||
self._application.workspaceLoaded.connect(self.setProjectName)
|
||||
self._application.getOutputDeviceManager().writeStarted.connect(self._onOutputStart)
|
||||
self._application.getMachineManager().rootMaterialChanged.connect(self._onActiveMaterialsChanged)
|
||||
self._application.getInstance().getPreferences().preferenceChanged.connect(self._onPreferencesChanged)
|
||||
|
||||
@ -301,10 +303,11 @@ class PrintInformation(QObject):
|
||||
if self._base_name == "":
|
||||
self._job_name = self.UNTITLED_JOB_NAME
|
||||
self._is_user_specified_job_name = False
|
||||
self._application.getController().getScene().clearMetaData()
|
||||
self.jobNameChanged.emit()
|
||||
return
|
||||
|
||||
base_name = self._stripAccents(self._base_name)
|
||||
base_name = self._base_name
|
||||
self._defineAbbreviatedMachineName()
|
||||
|
||||
# Only update the job name when it's not user-specified.
|
||||
@ -400,11 +403,6 @@ class PrintInformation(QObject):
|
||||
|
||||
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")
|
||||
def getFeaturePrintTimes(self) -> Dict[str, Duration]:
|
||||
result = {}
|
||||
@ -444,3 +442,11 @@ class PrintInformation(QObject):
|
||||
"""Listen to scene changes to check if we need to reset the print information"""
|
||||
|
||||
self.setToZeroPrintInformation(self._active_build_plate)
|
||||
|
||||
def _onOutputStart(self, output_device: OutputDevice) -> None:
|
||||
"""If this is the sort of output 'device' (like local or online file storage, rather than a printer),
|
||||
the user could have altered the file-name, and thus the project name should be altered as well."""
|
||||
if isinstance(output_device, ProjectOutputDevice):
|
||||
new_name = output_device.getLastOutputName()
|
||||
if new_name is not None:
|
||||
self.setJobName(os.path.splitext(os.path.basename(new_name))[0])
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import collections
|
||||
@ -6,9 +6,11 @@ from typing import Optional, Dict, List, cast
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSlot
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Resources import Resources
|
||||
from UM.Version import Version
|
||||
|
||||
catalog = i18nCatalog("cura")
|
||||
|
||||
#
|
||||
# This manager provides means to load texts to QML.
|
||||
@ -30,30 +32,33 @@ class TextManager(QObject):
|
||||
# Load change log texts and organize them with a dict
|
||||
try:
|
||||
file_path = Resources.getPath(Resources.Texts, "change_log.txt")
|
||||
except FileNotFoundError:
|
||||
except FileNotFoundError as e:
|
||||
# I have no idea how / when this happens, but we're getting crash reports about it.
|
||||
return ""
|
||||
return catalog.i18nc("@text:window", "The release notes could not be opened.") + "<br>" + str(e)
|
||||
change_logs_dict = {} # type: Dict[Version, Dict[str, List[str]]]
|
||||
with open(file_path, "r", encoding = "utf-8") as f:
|
||||
open_version = None # type: Optional[Version]
|
||||
open_header = "" # Initialise to an empty header in case there is no "*" in the first line of the changelog
|
||||
for line in f:
|
||||
line = line.replace("\n", "")
|
||||
if "[" in line and "]" in line:
|
||||
line = line.replace("[", "")
|
||||
line = line.replace("]", "")
|
||||
open_version = Version(line)
|
||||
if open_version > Version([14, 99, 99]): # Bit of a hack: We released the 15.x.x versions before 2.x
|
||||
open_version = Version([0, open_version.getMinor(), open_version.getRevision(), open_version.getPostfixVersion()])
|
||||
open_header = ""
|
||||
change_logs_dict[open_version] = collections.OrderedDict()
|
||||
elif line.startswith("*"):
|
||||
open_header = line.replace("*", "")
|
||||
change_logs_dict[cast(Version, open_version)][open_header] = []
|
||||
elif line != "":
|
||||
if open_header not in change_logs_dict[cast(Version, open_version)]:
|
||||
try:
|
||||
with open(file_path, "r", encoding = "utf-8") as f:
|
||||
open_version = None # type: Optional[Version]
|
||||
open_header = "" # Initialise to an empty header in case there is no "*" in the first line of the changelog
|
||||
for line in f:
|
||||
line = line.replace("\n", "")
|
||||
if "[" in line and "]" in line:
|
||||
line = line.replace("[", "")
|
||||
line = line.replace("]", "")
|
||||
open_version = Version(line)
|
||||
if open_version > Version([14, 99, 99]): # Bit of a hack: We released the 15.x.x versions before 2.x
|
||||
open_version = Version([0, open_version.getMinor(), open_version.getRevision(), open_version.getPostfixVersion()])
|
||||
open_header = ""
|
||||
change_logs_dict[open_version] = collections.OrderedDict()
|
||||
elif line.startswith("*"):
|
||||
open_header = line.replace("*", "")
|
||||
change_logs_dict[cast(Version, open_version)][open_header] = []
|
||||
change_logs_dict[cast(Version, open_version)][open_header].append(line)
|
||||
elif line != "":
|
||||
if open_header not in change_logs_dict[cast(Version, open_version)]:
|
||||
change_logs_dict[cast(Version, open_version)][open_header] = []
|
||||
change_logs_dict[cast(Version, open_version)][open_header].append(line)
|
||||
except EnvironmentError as e:
|
||||
return catalog.i18nc("@text:window", "The release notes could not be opened.") + "<br>" + str(e)
|
||||
|
||||
# Format changelog text
|
||||
content = ""
|
||||
|
@ -239,9 +239,6 @@ class WelcomePagesModel(ListModel):
|
||||
{"id": "user_agreement",
|
||||
"page_url": self._getBuiltinWelcomePagePath("UserAgreementContent.qml"),
|
||||
},
|
||||
{"id": "whats_new",
|
||||
"page_url": self._getBuiltinWelcomePagePath("WhatsNewContent.qml"),
|
||||
},
|
||||
{"id": "data_collections",
|
||||
"page_url": self._getBuiltinWelcomePagePath("DataCollectionsContent.qml"),
|
||||
},
|
||||
@ -259,13 +256,21 @@ class WelcomePagesModel(ListModel):
|
||||
},
|
||||
{"id": "add_cloud_printers",
|
||||
"page_url": self._getBuiltinWelcomePagePath("AddCloudPrintersView.qml"),
|
||||
"is_final_page": True, # If we end up in this page, the next button will close the dialog
|
||||
"next_page_button_text": self._catalog.i18nc("@action:button", "Finish"),
|
||||
"next_page_button_text": self._catalog.i18nc("@action:button", "Next"),
|
||||
"next_page_id": "whats_new",
|
||||
},
|
||||
{"id": "machine_actions",
|
||||
"page_url": self._getBuiltinWelcomePagePath("FirstStartMachineActionsContent.qml"),
|
||||
"should_show_function": self.shouldShowMachineActions,
|
||||
},
|
||||
{"id": "whats_new",
|
||||
"page_url": self._getBuiltinWelcomePagePath("WhatsNewContent.qml"),
|
||||
"next_page_button_text": self._catalog.i18nc("@action:button", "Skip"),
|
||||
},
|
||||
{"id": "changelog",
|
||||
"page_url": self._getBuiltinWelcomePagePath("ChangelogContent.qml"),
|
||||
"next_page_button_text": self._catalog.i18nc("@action:button", "Finish"),
|
||||
},
|
||||
]
|
||||
|
||||
pages_to_show = all_pages_list
|
||||
|
@ -1,8 +1,12 @@
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from .WelcomePagesModel import WelcomePagesModel
|
||||
|
||||
import os
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSlot
|
||||
from UM.Logger import Logger
|
||||
from UM.Resources import Resources
|
||||
|
||||
#
|
||||
# This Qt ListModel is more or less the same the WelcomePagesModel, except that this model is only for showing the
|
||||
@ -10,13 +14,84 @@ from .WelcomePagesModel import WelcomePagesModel
|
||||
#
|
||||
class WhatsNewPagesModel(WelcomePagesModel):
|
||||
|
||||
image_formats = [".png", ".jpg", ".jpeg", ".gif", ".svg"]
|
||||
text_formats = [".txt", ".htm", ".html"]
|
||||
image_key = "image"
|
||||
text_key = "text"
|
||||
|
||||
@staticmethod
|
||||
def _collectOrdinalFiles(resource_type: int, include: List[str]) -> Tuple[Dict[int, str], int]:
|
||||
result = {} #type: Dict[int, str]
|
||||
highest = -1
|
||||
try:
|
||||
folder_path = Resources.getPath(resource_type, "whats_new")
|
||||
for _, _, files in os.walk(folder_path):
|
||||
for filename in files:
|
||||
basename = os.path.basename(filename)
|
||||
base, ext = os.path.splitext(basename)
|
||||
if ext.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:
|
||||
self._pages = []
|
||||
self._pages.append({"id": "whats_new",
|
||||
"page_url": self._getBuiltinWelcomePagePath("WhatsNewContent.qml"),
|
||||
"next_page_button_text": self._catalog.i18nc("@action:button", "Skip"),
|
||||
"next_page_id": "changelog"
|
||||
})
|
||||
self._pages.append({"id": "changelog",
|
||||
"page_url": self._getBuiltinWelcomePagePath("ChangelogContent.qml"),
|
||||
"next_page_button_text": self._catalog.i18nc("@action:button", "Close"),
|
||||
})
|
||||
self.setItems(self._pages)
|
||||
|
||||
images, max_image = WhatsNewPagesModel._collectOrdinalFiles(Resources.Images, WhatsNewPagesModel.image_formats)
|
||||
texts, max_text = WhatsNewPagesModel._collectOrdinalFiles(Resources.Texts, WhatsNewPagesModel.text_formats)
|
||||
highest = max(max_image, max_text)
|
||||
|
||||
self._subpages = [] #type: List[Dict[str, Optional[str]]]
|
||||
for n in range(0, highest + 1):
|
||||
self._subpages.append({
|
||||
WhatsNewPagesModel.image_key: None if n not in images else images[n],
|
||||
WhatsNewPagesModel.text_key: None if n not in texts else self._loadText(texts[n])
|
||||
})
|
||||
if len(self._subpages) == 0:
|
||||
self._subpages.append({WhatsNewPagesModel.text_key: "~ There Is Nothing New Under The Sun ~"})
|
||||
|
||||
def _getSubpageItem(self, page: int, item: str) -> Optional[str]:
|
||||
if 0 <= page < self.subpageCount and item in self._subpages[page]:
|
||||
return self._subpages[page][item]
|
||||
else:
|
||||
return None
|
||||
|
||||
@pyqtProperty(int, constant = True)
|
||||
def subpageCount(self) -> int:
|
||||
return len(self._subpages)
|
||||
|
||||
@pyqtSlot(int, result = str)
|
||||
def getSubpageImageSource(self, page: int) -> str:
|
||||
result = self._getSubpageItem(page, WhatsNewPagesModel.image_key)
|
||||
return "file:///" + (result if result else Resources.getPath(Resources.Images, "cura-icon.png"))
|
||||
|
||||
@pyqtSlot(int, result = str)
|
||||
def getSubpageText(self, page: int) -> str:
|
||||
result = self._getSubpageItem(page, WhatsNewPagesModel.text_key)
|
||||
return result if result else "* * *"
|
||||
|
||||
__all__ = ["WhatsNewPagesModel"]
|
||||
|
@ -16,14 +16,6 @@ import argparse
|
||||
import faulthandler
|
||||
import os
|
||||
|
||||
# Workaround for a race condition on certain systems where there
|
||||
# is a race condition between Arcus and PyQt. Importing Arcus
|
||||
# first seems to prevent Sip from going into a state where it
|
||||
# tries to create PyQt objects on a non-main thread.
|
||||
import Arcus # @UnusedImport
|
||||
import Savitar # @UnusedImport
|
||||
import pynest2d # @UnusedImport
|
||||
|
||||
from PyQt5.QtNetwork import QSslConfiguration, QSslSocket
|
||||
|
||||
from UM.Platform import Platform
|
||||
|
@ -7,7 +7,7 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
PROJECT_DIR="$( cd "${SCRIPT_DIR}/.." && pwd )"
|
||||
|
||||
# Make sure that environment variables are set properly
|
||||
source /opt/rh/devtoolset-7/enable
|
||||
source /opt/rh/devtoolset-8/enable
|
||||
export PATH="${CURA_BUILD_ENV_PATH}/bin:${PATH}"
|
||||
export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}"
|
||||
|
||||
@ -50,7 +50,7 @@ do
|
||||
echo "Found Uranium branch [${URANIUM_BRANCH}]."
|
||||
break
|
||||
else
|
||||
echo "Could not find Uranium banch [${URANIUM_BRANCH}], try next."
|
||||
echo "Could not find Uranium branch [${URANIUM_BRANCH}], try next."
|
||||
fi
|
||||
done
|
||||
|
||||
|
@ -8,7 +8,7 @@ The build volume draws a cube (for rectangular build plates) that represents the
|
||||
|
||||
The build volume also draws a grid underneath the build volume. The grid features 1cm lines which allows the user to roughly estimate how big its print is or the distance between prints. It also features a finer 1mm line pattern within that grid. The grid is drawn as a single quad. This quad is then sent to the graphical card with a specialised shader which draws the grid pattern.
|
||||
|
||||
For elliptical build plates, the volume bounds are drawn as two circles, one at the top and one at the bottom of the available height. The build plate grid is drawn as a tesselated circle, but with the same shader.
|
||||
For elliptical build plates, the volume bounds are drawn as two circles, one at the top and one at the bottom of the available height. The build plate grid is drawn as a tessellated circle, but with the same shader.
|
||||
|
||||
Disallowed areas
|
||||
----
|
||||
|
BIN
docs/scene/images/components_interacting_with_scene.jpg
Normal file
After Width: | Height: | Size: 96 KiB |
BIN
docs/scene/images/components_interacting_with_scene.png
Normal file
After Width: | Height: | Size: 174 KiB |
BIN
docs/scene/images/layer_data_scene_node.jpg
Normal file
After Width: | Height: | Size: 79 KiB |
BIN
docs/scene/images/mirror_tool.jpg
Normal file
After Width: | Height: | Size: 51 KiB |
BIN
docs/scene/images/per_objectsettings_tool.jpg
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
docs/scene/images/rotate_tool.jpg
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
docs/scene/images/scale_tool.jpg
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
docs/scene/images/scene_example.jpg
Normal file
After Width: | Height: | Size: 196 KiB |
BIN
docs/scene/images/scene_example_scene_graph.jpg
Normal file
After Width: | Height: | Size: 345 KiB |
BIN
docs/scene/images/selection_tool.jpg
Normal file
After Width: | Height: | Size: 83 KiB |
BIN
docs/scene/images/support_blocker_tool.jpg
Normal file
After Width: | Height: | Size: 72 KiB |
BIN
docs/scene/images/tools_tool-handles_class_diagram.jpg
Normal file
After Width: | Height: | Size: 69 KiB |
BIN
docs/scene/images/translate_tool.jpg
Normal file
After Width: | Height: | Size: 27 KiB |
113
docs/scene/operations.md
Normal 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`.
|
||||
|
@ -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.
|
||||
|
||||
Class Diagram
|
||||
----
|
||||
|
||||
The following class diagram depicts the classes that interact with the Scene
|
||||
|
||||

|
||||
|
||||
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
|
||||
----
|
||||
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
|
||||
* Camera
|
||||
* [Build volume](build_volume.md)
|
||||
* Platform
|
||||
* Object 1
|
||||
* Group 1
|
||||
* Object 2
|
||||
* Object 3
|
||||
* Object 1 convex hull node
|
||||
* Object 2 convex hull node
|
||||
* Object 3 convex hull node
|
||||
* Group 1 convex hull node
|
||||
Let's take the following example Scene:
|
||||
|
||||

|
||||
|
||||
The scene graph in this case is the following:
|
||||
|
||||
|
||||

|
||||
|
||||
**Note 1:** The Platform is actually a child of the BuildVolume.
|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
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
@ -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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import os.path
|
||||
@ -51,6 +51,10 @@ class ThreeMFReader(MeshReader):
|
||||
self._root = None
|
||||
self._base_name = ""
|
||||
self._unit = None
|
||||
self._empty_project = False
|
||||
|
||||
def emptyFileHintSet(self) -> bool:
|
||||
return self._empty_project
|
||||
|
||||
def _createMatrixFromTransformationString(self, transformation: str) -> Matrix:
|
||||
if transformation == "":
|
||||
@ -159,9 +163,9 @@ class ThreeMFReader(MeshReader):
|
||||
um_node.callDecoration("getStack").getTop().setDefinition(definition_id)
|
||||
|
||||
setting_container = um_node.callDecoration("getStack").getTop()
|
||||
|
||||
known_setting_keys = um_node.callDecoration("getStack").getAllKeys()
|
||||
for key in settings:
|
||||
setting_value = settings[key]
|
||||
setting_value = settings[key].value
|
||||
|
||||
# Extruder_nr is a special case.
|
||||
if key == "extruder_nr":
|
||||
@ -171,7 +175,10 @@ class ThreeMFReader(MeshReader):
|
||||
else:
|
||||
Logger.log("w", "Unable to find extruder in position %s", setting_value)
|
||||
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.getAllChildren()) == 1:
|
||||
@ -193,6 +200,7 @@ class ThreeMFReader(MeshReader):
|
||||
return um_node
|
||||
|
||||
def _read(self, file_name: str) -> Union[SceneNode, List[SceneNode]]:
|
||||
self._empty_project = False
|
||||
result = []
|
||||
# The base object of 3mf is a zipped archive.
|
||||
try:
|
||||
@ -201,6 +209,10 @@ class ThreeMFReader(MeshReader):
|
||||
parser = Savitar.ThreeMFParser()
|
||||
scene_3mf = parser.parse(archive.open("3D/3dmodel.model").read())
|
||||
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():
|
||||
um_node = self._convertSavitarNodeToUMNode(node, file_name)
|
||||
if um_node is None:
|
||||
@ -257,6 +269,9 @@ class ThreeMFReader(MeshReader):
|
||||
|
||||
result.append(um_node)
|
||||
|
||||
if len(result) == 0:
|
||||
self._empty_project = True
|
||||
|
||||
except Exception:
|
||||
Logger.logException("e", "An exception occurred in 3mf reader.")
|
||||
return []
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from configparser import ConfigParser
|
||||
@ -412,7 +412,12 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
|
||||
quality_container_id = parser["containers"][str(_ContainerIndexes.Quality)]
|
||||
quality_type = "empty_quality"
|
||||
if quality_container_id not in ("empty", "empty_quality"):
|
||||
quality_type = instance_container_info_dict[quality_container_id].parser["metadata"]["quality_type"]
|
||||
if quality_container_id in instance_container_info_dict:
|
||||
quality_type = instance_container_info_dict[quality_container_id].parser["metadata"]["quality_type"]
|
||||
else: # If a version upgrade changed the quality profile in the stack, we'll need to look for it in the built-in profiles instead of the workspace.
|
||||
quality_matches = ContainerRegistry.getInstance().findContainersMetadata(id = quality_container_id)
|
||||
if quality_matches: # If there's no profile with this ID, leave it empty_quality.
|
||||
quality_type = quality_matches[0]["quality_type"]
|
||||
|
||||
# Get machine info
|
||||
serialized = archive.open(global_stack_file).read().decode("utf-8")
|
||||
@ -535,7 +540,8 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
|
||||
"Project file <filename>{0}</filename> contains an unknown machine type"
|
||||
" <message>{1}</message>. Cannot import the machine."
|
||||
" Models will be imported instead.", file_name, machine_definition_id),
|
||||
title = i18n_catalog.i18nc("@info:title", "Open Project File"))
|
||||
title = i18n_catalog.i18nc("@info:title", "Open Project File"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
message.show()
|
||||
|
||||
Logger.log("i", "Could unknown machine definition %s in project file %s, cannot import it.",
|
||||
@ -632,14 +638,16 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
|
||||
except EnvironmentError as e:
|
||||
message = Message(i18n_catalog.i18nc("@info:error Don't translate the XML tags <filename> or <message>!",
|
||||
"Project file <filename>{0}</filename> is suddenly inaccessible: <message>{1}</message>.", file_name, str(e)),
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"))
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
message.show()
|
||||
self.setWorkspaceName("")
|
||||
return [], {}
|
||||
except zipfile.BadZipFile as e:
|
||||
message = Message(i18n_catalog.i18nc("@info:error Don't translate the XML tags <filename> or <message>!",
|
||||
"Project file <filename>{0}</filename> is corrupt: <message>{1}</message>.", file_name, str(e)),
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"))
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Open Project File"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
message.show()
|
||||
self.setWorkspaceName("")
|
||||
return [], {}
|
||||
@ -691,7 +699,8 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
|
||||
if not global_stacks:
|
||||
message = Message(i18n_catalog.i18nc("@info:error Don't translate the XML tag <filename>!",
|
||||
"Project file <filename>{0}</filename> is made using profiles that"
|
||||
" are unknown to this version of Ultimaker Cura.", file_name))
|
||||
" are unknown to this version of Ultimaker Cura.", file_name),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
message.show()
|
||||
self.setWorkspaceName("")
|
||||
return [], {}
|
||||
@ -1157,7 +1166,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
|
||||
return
|
||||
machine_manager.setQualityChangesGroup(quality_changes_group, no_dialog = True)
|
||||
else:
|
||||
self._quality_type_to_apply = self._quality_type_to_apply.lower()
|
||||
self._quality_type_to_apply = self._quality_type_to_apply.lower() if self._quality_type_to_apply else None
|
||||
quality_group_dict = container_tree.getCurrentQualityGroups()
|
||||
if self._quality_type_to_apply in quality_group_dict:
|
||||
quality_group = quality_group_dict[self._quality_type_to_apply]
|
||||
|
@ -419,7 +419,7 @@ UM.Dialog
|
||||
width: warningLabel.height
|
||||
height: width
|
||||
|
||||
source: UM.Theme.getIcon("notice")
|
||||
source: UM.Theme.getIcon("Information")
|
||||
color: palette.text
|
||||
|
||||
}
|
||||
|
@ -3,6 +3,6 @@
|
||||
"author": "Ultimaker B.V.",
|
||||
"version": "1.0.1",
|
||||
"description": "Provides support for reading 3MF files.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ from cura.CuraApplication import CuraApplication
|
||||
import Savitar
|
||||
|
||||
import numpy
|
||||
import datetime
|
||||
|
||||
MYPY = False
|
||||
try:
|
||||
@ -108,7 +109,11 @@ class ThreeMFWriter(MeshWriter):
|
||||
|
||||
# Get values for all changed settings & save them.
|
||||
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():
|
||||
# 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")
|
||||
|
||||
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._data[1, 1] = 0
|
||||
transformation_matrix._data[1, 2] = -1
|
||||
|
@ -3,6 +3,6 @@
|
||||
"author": "Ultimaker B.V.",
|
||||
"version": "1.0.1",
|
||||
"description": "Provides support for writing 3MF files.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
@ -157,22 +157,22 @@ class AMFReader(MeshReader):
|
||||
tri_faces = tri_node.faces
|
||||
tri_vertices = tri_node.vertices
|
||||
|
||||
indices = []
|
||||
vertices = []
|
||||
indices_list = []
|
||||
vertices_list = []
|
||||
|
||||
index_count = 0
|
||||
face_count = 0
|
||||
for tri_face in tri_faces:
|
||||
face = []
|
||||
for tri_index in tri_face:
|
||||
vertices.append(tri_vertices[tri_index])
|
||||
vertices_list.append(tri_vertices[tri_index])
|
||||
face.append(index_count)
|
||||
index_count += 1
|
||||
indices.append(face)
|
||||
indices_list.append(face)
|
||||
face_count += 1
|
||||
|
||||
vertices = numpy.asarray(vertices, dtype = numpy.float32)
|
||||
indices = numpy.asarray(indices, dtype = numpy.int32)
|
||||
vertices = numpy.asarray(vertices_list, dtype = numpy.float32)
|
||||
indices = numpy.asarray(indices_list, dtype = numpy.int32)
|
||||
normals = calculateNormalsFromIndexedVertices(vertices, indices, face_count)
|
||||
|
||||
mesh_data = MeshData(vertices = vertices, indices = indices, normals = normals,file_name = file_name)
|
||||
|
@ -3,5 +3,5 @@
|
||||
"author": "fieldOfView",
|
||||
"version": "1.0.0",
|
||||
"description": "Provides support for reading AMF files.",
|
||||
"api": "7.4.0"
|
||||
"api": 7
|
||||
}
|
||||
|
@ -3,6 +3,6 @@
|
||||
"author": "Ultimaker B.V.",
|
||||
"description": "Backup and restore your configuration.",
|
||||
"version": "1.2.0",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ import threading
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import sentry_sdk
|
||||
from PyQt5.QtNetwork import QNetworkReply
|
||||
|
||||
from UM.Job import Job
|
||||
@ -44,7 +43,9 @@ class CreateBackupJob(Job):
|
||||
"""After the job completes, an empty string indicates success. Othrerwise, the value is a translated message."""
|
||||
|
||||
def run(self) -> None:
|
||||
upload_message = Message(catalog.i18nc("@info:backup_status", "Creating your backup..."), title = self.MESSAGE_TITLE, progress = -1)
|
||||
upload_message = Message(catalog.i18nc("@info:backup_status", "Creating your backup..."),
|
||||
title = self.MESSAGE_TITLE,
|
||||
progress = -1)
|
||||
upload_message.show()
|
||||
CuraApplication.getInstance().processEvents()
|
||||
cura_api = CuraApplication.getInstance().getCuraAPI()
|
||||
@ -99,13 +100,7 @@ class CreateBackupJob(Job):
|
||||
if HttpRequestManager.safeHttpStatus(reply) == 400:
|
||||
errors = json.loads(replyText)["errors"]
|
||||
if "moreThanMaximum" in [error["code"] for error in errors if error["meta"] and error["meta"]["field_name"] == "backup_size"]:
|
||||
if self._backup_zip is None: # will never happen; keep mypy happy
|
||||
zip_error = "backup is None."
|
||||
else:
|
||||
zip_error = "{} exceeds max size.".format(str(len(self._backup_zip)))
|
||||
sentry_sdk.capture_message("backup failed: {}".format(zip_error), level ="warning")
|
||||
self.backup_upload_error_message = catalog.i18nc("@error:file_size", "The backup exceeds the maximum file size.")
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
self._job_done.set()
|
||||
return
|
||||
|
@ -43,6 +43,10 @@ class DriveApiService:
|
||||
return
|
||||
|
||||
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:
|
||||
Logger.log("w", "Could not get backups from remote, actual response body was: %s",
|
||||
str(backup_list_response))
|
||||
@ -89,7 +93,7 @@ class DriveApiService:
|
||||
def _onRestoreFinished(self, job: "RestoreBackupJob") -> None:
|
||||
if job.restore_backup_error_message != "":
|
||||
# If the job contains an error message we pass it along so the UI can display it.
|
||||
self.restoringStateChanged.emit(is_restoring=False)
|
||||
self.restoringStateChanged.emit(is_restoring = False)
|
||||
else:
|
||||
self.restoringStateChanged.emit(is_restoring = False, error_message = job.restore_backup_error_message)
|
||||
|
||||
|
@ -34,6 +34,9 @@ class DrivePluginExtension(QObject, Extension):
|
||||
# Signal emitted when preferences changed (like auto-backup).
|
||||
preferencesChanged = pyqtSignal()
|
||||
|
||||
# Signal emitted when the id of the backup-to-be-restored is changed
|
||||
backupIdBeingRestoredChanged = pyqtSignal(arguments = ["backup_id_being_restored"])
|
||||
|
||||
DATE_FORMAT = "%d/%m/%Y %H:%M:%S"
|
||||
|
||||
def __init__(self) -> None:
|
||||
@ -45,6 +48,7 @@ class DrivePluginExtension(QObject, Extension):
|
||||
self._backups = [] # type: List[Dict[str, Any]]
|
||||
self._is_restoring_backup = False
|
||||
self._is_creating_backup = False
|
||||
self._backup_id_being_restored = ""
|
||||
|
||||
# Initialize services.
|
||||
preferences = CuraApplication.getInstance().getPreferences()
|
||||
@ -52,6 +56,7 @@ class DrivePluginExtension(QObject, Extension):
|
||||
|
||||
# Attach signals.
|
||||
CuraApplication.getInstance().getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged)
|
||||
CuraApplication.getInstance().applicationShuttingDown.connect(self._onApplicationShuttingDown)
|
||||
self._drive_api_service.restoringStateChanged.connect(self._onRestoringStateChanged)
|
||||
self._drive_api_service.creatingStateChanged.connect(self._onCreatingStateChanged)
|
||||
|
||||
@ -75,6 +80,10 @@ class DrivePluginExtension(QObject, Extension):
|
||||
if self._drive_window:
|
||||
self._drive_window.show()
|
||||
|
||||
def _onApplicationShuttingDown(self):
|
||||
if self._drive_window:
|
||||
self._drive_window.hide()
|
||||
|
||||
def _autoBackup(self) -> None:
|
||||
preferences = CuraApplication.getInstance().getPreferences()
|
||||
if preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY) and self._isLastBackupTooLongAgo():
|
||||
@ -100,17 +109,22 @@ class DrivePluginExtension(QObject, Extension):
|
||||
if logged_in:
|
||||
self.refreshBackups()
|
||||
|
||||
def _onRestoringStateChanged(self, is_restoring: bool = False, error_message: str = None) -> None:
|
||||
def _onRestoringStateChanged(self, is_restoring: bool = False, error_message: Optional[str] = None) -> None:
|
||||
self._is_restoring_backup = is_restoring
|
||||
self.restoringStateChanged.emit()
|
||||
if error_message:
|
||||
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
|
||||
self.backupIdBeingRestored = ""
|
||||
Message(error_message,
|
||||
title = catalog.i18nc("@info:title", "Backup"),
|
||||
message_type = Message.MessageType.ERROR).show()
|
||||
|
||||
def _onCreatingStateChanged(self, is_creating: bool = False, error_message: str = None) -> None:
|
||||
self._is_creating_backup = is_creating
|
||||
self.creatingStateChanged.emit()
|
||||
if error_message:
|
||||
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
|
||||
Message(error_message,
|
||||
title = catalog.i18nc("@info:title", "Backup"),
|
||||
message_type = Message.MessageType.ERROR).show()
|
||||
else:
|
||||
self._storeBackupDate()
|
||||
if not is_creating and not error_message:
|
||||
@ -152,6 +166,7 @@ class DrivePluginExtension(QObject, Extension):
|
||||
for backup in self._backups:
|
||||
if backup.get("backup_id") == backup_id:
|
||||
self._drive_api_service.restoreBackup(backup)
|
||||
self.setBackupIdBeingRestored(backup_id)
|
||||
return
|
||||
Logger.log("w", "Unable to find backup with the ID %s", backup_id)
|
||||
|
||||
@ -166,3 +181,12 @@ class DrivePluginExtension(QObject, Extension):
|
||||
def _backupDeletedCallback(self, success: bool):
|
||||
if success:
|
||||
self.refreshBackups()
|
||||
|
||||
def setBackupIdBeingRestored(self, backup_id_being_restored: str) -> None:
|
||||
if backup_id_being_restored != self._backup_id_being_restored:
|
||||
self._backup_id_being_restored = backup_id_being_restored
|
||||
self.backupIdBeingRestoredChanged.emit()
|
||||
|
||||
@pyqtProperty(str, fset = setBackupIdBeingRestored, notify = backupIdBeingRestoredChanged)
|
||||
def backupIdBeingRestored(self) -> str:
|
||||
return self._backup_id_being_restored
|
||||
|
@ -1,3 +1,6 @@
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import threading
|
||||
@ -56,14 +59,20 @@ class RestoreBackupJob(Job):
|
||||
return
|
||||
|
||||
# We store the file in a temporary path fist to ensure integrity.
|
||||
temporary_backup_file = NamedTemporaryFile(delete = False)
|
||||
with open(temporary_backup_file.name, "wb") as write_backup:
|
||||
app = CuraApplication.getInstance()
|
||||
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
|
||||
while bytes_read:
|
||||
write_backup.write(bytes_read)
|
||||
try:
|
||||
temporary_backup_file = NamedTemporaryFile(delete = False)
|
||||
with open(temporary_backup_file.name, "wb") as write_backup:
|
||||
app = CuraApplication.getInstance()
|
||||
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
|
||||
app.processEvents()
|
||||
while bytes_read:
|
||||
write_backup.write(bytes_read)
|
||||
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
|
||||
app.processEvents()
|
||||
except EnvironmentError as e:
|
||||
Logger.log("e", f"Unable to save backed up files due to computer limitations: {str(e)}")
|
||||
self.restore_backup_error_message = self.DEFAULT_ERROR_MESSAGE
|
||||
self._job_done.set()
|
||||
return
|
||||
|
||||
if not self._verifyMd5Hash(temporary_backup_file.name, self._backup.get("md5_hash", "")):
|
||||
# Don't restore the backup if the MD5 hashes do not match.
|
||||
|
@ -20,7 +20,7 @@ RowLayout
|
||||
{
|
||||
id: infoButton
|
||||
text: catalog.i18nc("@button", "Want more?")
|
||||
iconSource: UM.Theme.getIcon("info")
|
||||
iconSource: UM.Theme.getIcon("Information")
|
||||
onClicked: Qt.openUrlExternally("https://goo.gl/forms/QACEP8pP3RV60QYG2")
|
||||
visible: backupListFooter.showInfoButton
|
||||
}
|
||||
@ -29,7 +29,7 @@ RowLayout
|
||||
{
|
||||
id: createBackupButton
|
||||
text: catalog.i18nc("@button", "Backup Now")
|
||||
iconSource: UM.Theme.getIcon("plus")
|
||||
iconSource: UM.Theme.getIcon("Plus")
|
||||
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
|
||||
onClicked: CuraDrive.createBackup()
|
||||
busy: CuraDrive.isCreatingBackup
|
||||
|
@ -38,7 +38,7 @@ Item
|
||||
height: UM.Theme.getSize("section_icon").height
|
||||
color: UM.Theme.getColor("small_button_text")
|
||||
hoverColor: UM.Theme.getColor("small_button_text_hover")
|
||||
iconSource: UM.Theme.getIcon("info")
|
||||
iconSource: UM.Theme.getIcon("Information")
|
||||
onClicked: backupListItem.showDetails = !backupListItem.showDetails
|
||||
}
|
||||
|
||||
@ -71,6 +71,7 @@ Item
|
||||
text: catalog.i18nc("@button", "Restore")
|
||||
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
|
||||
onClicked: confirmRestoreDialog.visible = true
|
||||
busy: CuraDrive.backupIdBeingRestored == modelData.backup_id && CuraDrive.isRestoringBackup
|
||||
}
|
||||
|
||||
UM.SimpleButton
|
||||
@ -79,7 +80,7 @@ Item
|
||||
height: UM.Theme.getSize("message_close").height
|
||||
color: UM.Theme.getColor("small_button_text")
|
||||
hoverColor: UM.Theme.getColor("small_button_text_hover")
|
||||
iconSource: UM.Theme.getIcon("cross1")
|
||||
iconSource: UM.Theme.getIcon("Cancel")
|
||||
onClicked: confirmDeleteDialog.visible = true
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
// Copyright (c) 2018 Ultimaker B.V.
|
||||
// Copyright (c) 2021 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.7
|
||||
@ -17,7 +17,7 @@ ColumnLayout
|
||||
// Cura version
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("application")
|
||||
iconSource: UM.Theme.getIcon("UltimakerCura")
|
||||
label: catalog.i18nc("@backuplist:label", "Cura Version")
|
||||
value: backupDetailsData.metadata.cura_release
|
||||
}
|
||||
@ -25,7 +25,7 @@ ColumnLayout
|
||||
// Machine count.
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("printer_single")
|
||||
iconSource: UM.Theme.getIcon("Printer")
|
||||
label: catalog.i18nc("@backuplist:label", "Machines")
|
||||
value: backupDetailsData.metadata.machine_count
|
||||
}
|
||||
@ -33,7 +33,7 @@ ColumnLayout
|
||||
// Material count
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("category_material")
|
||||
iconSource: UM.Theme.getIcon("Spool")
|
||||
label: catalog.i18nc("@backuplist:label", "Materials")
|
||||
value: backupDetailsData.metadata.material_count
|
||||
}
|
||||
@ -41,7 +41,7 @@ ColumnLayout
|
||||
// Profile count.
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("settings")
|
||||
iconSource: UM.Theme.getIcon("Sliders")
|
||||
label: catalog.i18nc("@backuplist:label", "Profiles")
|
||||
value: backupDetailsData.metadata.profile_count
|
||||
}
|
||||
@ -49,7 +49,7 @@ ColumnLayout
|
||||
// Plugin count.
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("plugin")
|
||||
iconSource: UM.Theme.getIcon("Plugin")
|
||||
label: catalog.i18nc("@backuplist:label", "Plugins")
|
||||
value: backupDetailsData.metadata.plugin_count
|
||||
}
|
||||
|
@ -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.
|
||||
|
||||
import argparse #To run the engine in debug mode if the front-end is in debug mode.
|
||||
from collections import defaultdict
|
||||
import os
|
||||
from PyQt5.QtCore import QObject, QTimer, pyqtSlot
|
||||
from PyQt5.QtCore import QObject, QTimer, QUrl, pyqtSlot
|
||||
import sys
|
||||
from time import time
|
||||
from typing import Any, cast, Dict, List, Optional, Set, TYPE_CHECKING
|
||||
|
||||
from PyQt5.QtGui import QDesktopServices, QImage
|
||||
|
||||
from UM.Backend.Backend import Backend, BackendState
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from UM.Signal import Signal
|
||||
@ -24,6 +26,8 @@ from UM.Tool import Tool #For typing.
|
||||
|
||||
from cura.CuraApplication import CuraApplication
|
||||
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 .StartSliceJob import StartSliceJob, StartJobResult
|
||||
|
||||
@ -153,6 +157,21 @@ class CuraEngineBackend(QObject, Backend):
|
||||
self.determineAutoSlicing()
|
||||
application.getPreferences().preferenceChanged.connect(self._onPreferencesChanged)
|
||||
|
||||
self._slicing_error_message = Message(
|
||||
text = catalog.i18nc("@message", "Slicing failed with an unexpected error. Please consider reporting a bug on our issue tracker."),
|
||||
title = catalog.i18nc("@message:title", "Slicing failed"),
|
||||
message_type = Message.MessageType.ERROR
|
||||
)
|
||||
self._slicing_error_message.addAction(
|
||||
action_id = "report_bug",
|
||||
name = catalog.i18nc("@message:button", "Report a bug"),
|
||||
description = catalog.i18nc("@message:description", "Report a bug on Ultimaker Cura's issue tracker."),
|
||||
icon = "[no_icon]"
|
||||
)
|
||||
self._slicing_error_message.actionTriggered.connect(self._reportBackendError)
|
||||
|
||||
self._snapshot = None #type: Optional[QImage]
|
||||
|
||||
application.initializationFinished.connect(self.initialize)
|
||||
|
||||
def initialize(self) -> None:
|
||||
@ -241,9 +260,27 @@ class CuraEngineBackend(QObject, Backend):
|
||||
self.markSliceAll()
|
||||
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:
|
||||
"""Perform a slice of the scene."""
|
||||
|
||||
self._createSnapshot()
|
||||
|
||||
Logger.log("i", "Starting to slice...")
|
||||
self._slice_start_time = time()
|
||||
if not self._build_plates_to_be_sliced:
|
||||
@ -331,7 +368,6 @@ class CuraEngineBackend(QObject, Backend):
|
||||
|
||||
def _onStartSliceCompleted(self, job: StartSliceJob) -> None:
|
||||
"""Event handler to call when the job to initiate the slicing process is
|
||||
|
||||
completed.
|
||||
|
||||
When the start slice job is successfully completed, it will be happily
|
||||
@ -356,7 +392,9 @@ class CuraEngineBackend(QObject, Backend):
|
||||
if job.getResult() == StartJobResult.MaterialIncompatible:
|
||||
if application.platformActivity:
|
||||
self._error_message = Message(catalog.i18nc("@info:status",
|
||||
"Unable to slice with the current material as it is incompatible with the selected machine or configuration."), title = catalog.i18nc("@info:title", "Unable to slice"))
|
||||
"Unable to slice with the current material as it is incompatible with the selected machine or configuration."),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
self._error_message.show()
|
||||
self.setState(BackendState.Error)
|
||||
self.backendError.emit(job)
|
||||
@ -386,8 +424,10 @@ class CuraEngineBackend(QObject, Backend):
|
||||
continue
|
||||
error_labels.add(definitions[0].label)
|
||||
|
||||
self._error_message = Message(catalog.i18nc("@info:status", "Unable to slice with the current settings. The following settings have errors: {0}").format(", ".join(error_labels)),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"))
|
||||
self._error_message = Message(catalog.i18nc("@info:status",
|
||||
"Unable to slice with the current settings. The following settings have errors: {0}").format(", ".join(error_labels)),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
self._error_message.show()
|
||||
self.setState(BackendState.Error)
|
||||
self.backendError.emit(job)
|
||||
@ -410,8 +450,10 @@ class CuraEngineBackend(QObject, Backend):
|
||||
Logger.log("e", "When checking settings for errors, unable to find definition for key {key} in per-object stack.".format(key = key))
|
||||
continue
|
||||
errors[key] = definition[0].label
|
||||
self._error_message = Message(catalog.i18nc("@info:status", "Unable to slice due to some per-model settings. The following settings have errors on one or more models: {error_labels}").format(error_labels = ", ".join(errors.values())),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"))
|
||||
self._error_message = Message(catalog.i18nc("@info:status",
|
||||
"Unable to slice due to some per-model settings. The following settings have errors on one or more models: {error_labels}").format(error_labels = ", ".join(errors.values())),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
self._error_message.show()
|
||||
self.setState(BackendState.Error)
|
||||
self.backendError.emit(job)
|
||||
@ -419,17 +461,22 @@ class CuraEngineBackend(QObject, Backend):
|
||||
|
||||
if job.getResult() == StartJobResult.BuildPlateError:
|
||||
if application.platformActivity:
|
||||
self._error_message = Message(catalog.i18nc("@info:status", "Unable to slice because the prime tower or prime position(s) are invalid."),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"))
|
||||
self._error_message = Message(catalog.i18nc("@info:status",
|
||||
"Unable to slice because the prime tower or prime position(s) are invalid."),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
self._error_message.show()
|
||||
self.setState(BackendState.Error)
|
||||
self.backendError.emit(job)
|
||||
return
|
||||
else:
|
||||
self.setState(BackendState.NotStarted)
|
||||
|
||||
if job.getResult() == StartJobResult.ObjectsWithDisabledExtruder:
|
||||
self._error_message = Message(catalog.i18nc("@info:status", "Unable to slice because there are objects associated with disabled Extruder %s.") % job.getMessage(),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"))
|
||||
self._error_message = Message(catalog.i18nc("@info:status",
|
||||
"Unable to slice because there are objects associated with disabled Extruder %s.") % job.getMessage(),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
self._error_message.show()
|
||||
self.setState(BackendState.Error)
|
||||
self.backendError.emit(job)
|
||||
@ -441,7 +488,8 @@ class CuraEngineBackend(QObject, Backend):
|
||||
"\n- Fit within the build volume"
|
||||
"\n- Are assigned to an enabled extruder"
|
||||
"\n- Are not all set as modifier meshes"),
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"))
|
||||
title = catalog.i18nc("@info:title", "Unable to slice"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
self._error_message.show()
|
||||
self.setState(BackendState.Error)
|
||||
self.backendError.emit(job)
|
||||
@ -599,7 +647,7 @@ class CuraEngineBackend(QObject, Backend):
|
||||
for node in DepthFirstIterator(self._scene.getRoot()):
|
||||
if node.callDecoration("getLayerData"):
|
||||
if not build_plate_numbers or node.callDecoration("getBuildPlateNumber") in build_plate_numbers:
|
||||
# We can asume that all nodes have a parent as we're looping through the scene (and filter out root)
|
||||
# We can assume that all nodes have a parent as we're looping through the scene (and filter out root)
|
||||
cast(SceneNode, node.getParent()).removeChild(node)
|
||||
|
||||
def markSliceAll(self) -> None:
|
||||
@ -899,9 +947,22 @@ class CuraEngineBackend(QObject, Backend):
|
||||
|
||||
if not self._restart:
|
||||
if self._process: # type: ignore
|
||||
Logger.log("d", "Backend quit with return code %s. Resetting process and socket.", self._process.wait()) # type: ignore
|
||||
return_code = self._process.wait()
|
||||
if return_code != 0:
|
||||
Logger.log("e", f"Backend exited abnormally with return code {return_code}!")
|
||||
self._slicing_error_message.show()
|
||||
self.setState(BackendState.Error)
|
||||
self.stopSlicing()
|
||||
else:
|
||||
Logger.log("d", "Backend finished slicing. Resetting process and socket.")
|
||||
self._process = None # type: ignore
|
||||
|
||||
def _reportBackendError(self, _message_id: str, _action_id: str) -> None:
|
||||
"""
|
||||
Triggered when the user wants to report an error in the back-end.
|
||||
"""
|
||||
QDesktopServices.openUrl(QUrl("https://github.com/Ultimaker/Cura/issues/new/choose"))
|
||||
|
||||
def _onGlobalStackChanged(self) -> None:
|
||||
"""Called when the global container stack changes"""
|
||||
|
||||
|
@ -257,10 +257,10 @@ class ProcessSlicedLayersJob(Job):
|
||||
if self.isRunning():
|
||||
if Application.getInstance().getController().getActiveView().getPluginId() == "SimulationView":
|
||||
if not self._progress_message:
|
||||
self._progress_message = Message(catalog.i18nc("@info:status", "Processing Layers"), 0, False, 0, catalog.i18nc("@info:title", "Information"))
|
||||
self._progress_message = Message(catalog.i18nc("@info:status", "Processing Layers"), 0, False, 0,
|
||||
catalog.i18nc("@info:title", "Information"))
|
||||
if self._progress_message.getProgress() != 100:
|
||||
self._progress_message.show()
|
||||
else:
|
||||
if self._progress_message:
|
||||
self._progress_message.hide()
|
||||
|
||||
|
@ -195,7 +195,7 @@ class StartSliceJob(Job):
|
||||
# Remove old layer data.
|
||||
for node in DepthFirstIterator(self._scene.getRoot()):
|
||||
if node.callDecoration("getLayerData") and node.callDecoration("getBuildPlateNumber") == self._build_plate_number:
|
||||
# Singe we walk through all nodes in the scene, they always have a parent.
|
||||
# Since we walk through all nodes in the scene, they always have a parent.
|
||||
cast(SceneNode, node.getParent()).removeChild(node)
|
||||
break
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
"name": "CuraEngine Backend",
|
||||
"author": "Ultimaker B.V.",
|
||||
"description": "Provides the link to the CuraEngine slicing backend.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"version": "1.0.1",
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|