Merge branch 'libArachne_rebased' into back_pressure_compensation

This commit is contained in:
Remco Burema 2021-08-06 13:08:35 +02:00
commit fbbec8c012
No known key found for this signature in database
GPG key ID: 215C49431D43F98C
5339 changed files with 559595 additions and 563821 deletions

View file

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

View file

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

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

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

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

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

View file

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

View file

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

13
.github/no-response.yml vendored Normal file
View file

@ -0,0 +1,13 @@
# Configuration for probot-no-response - https://github.com/probot/no-response
# Number of days of inactivity before an Issue is closed for lack of response
daysUntilClose: 14
# Label requiring a response
responseRequiredLabel: 'Status: Needs Info'
# Comment to post when closing an Issue for lack of response. Set to `false` to disable
closeComment: >
This issue has been automatically closed because there has been no response
to our request for more information from the original author. With only the
information that is currently in the issue, we don't have enough information
to take action. Please reach out if you have or find the answers we need so
that we can investigate further.

View file

@ -1,5 +1,5 @@
--- ---
name: CI/CD name: CI
on: on:
push: push:
branches: branches:
@ -10,11 +10,12 @@ on:
pull_request: pull_request:
jobs: jobs:
build: build:
name: Build and test
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: ultimaker/cura-build-environment container: ultimaker/cura-build-environment
steps: steps:
- name: Checkout Cura - name: Checkout Cura
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Build and test - name: Build
run: docker/build.sh run: docker/build.sh
- name: Test
run: docker/test.sh

4
.gitignore vendored
View file

@ -53,6 +53,8 @@ plugins/GodMode
plugins/OctoPrintPlugin plugins/OctoPrintPlugin
plugins/ProfileFlattener plugins/ProfileFlattener
plugins/SettingsGuide plugins/SettingsGuide
plugins/SettingsGuide2
plugins/SVGToolpathReader
plugins/X3GWriter plugins/X3GWriter
#Build stuff #Build stuff
@ -76,3 +78,5 @@ CuraEngine
#Prevents import failures when plugin running tests #Prevents import failures when plugin running tests
plugins/__init__.py plugins/__init__.py
/venv

11
CITATION.cff Normal file
View 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"
...

View file

@ -16,6 +16,8 @@ if(CURA_DEBUGMODE)
set(_cura_debugmode "ON") set(_cura_debugmode "ON")
endif() endif()
option(GENERATE_TRANSLATIONS "Should the translations be generated?" ON)
set(CURA_APP_NAME "cura" CACHE STRING "Short name of Cura, used for configuration folder") set(CURA_APP_NAME "cura" CACHE STRING "Short name of Cura, used for configuration folder")
set(CURA_APP_DISPLAY_NAME "Ultimaker Cura" CACHE STRING "Display name of Cura") set(CURA_APP_DISPLAY_NAME "Ultimaker Cura" CACHE STRING "Display name of Cura")
set(CURA_VERSION "master" CACHE STRING "Version name of Cura") set(CURA_VERSION "master" CACHE STRING "Version name of Cura")
@ -24,30 +26,23 @@ set(CURA_CLOUD_API_ROOT "" CACHE STRING "Alternative Cura cloud API root")
set(CURA_CLOUD_API_VERSION "" CACHE STRING "Alternative Cura cloud API version") set(CURA_CLOUD_API_VERSION "" CACHE STRING "Alternative Cura cloud API version")
set(CURA_CLOUD_ACCOUNT_API_ROOT "" CACHE STRING "Alternative Cura cloud account API version") set(CURA_CLOUD_ACCOUNT_API_ROOT "" CACHE STRING "Alternative Cura cloud account API version")
set(CURA_MARKETPLACE_ROOT "" CACHE STRING "Alternative Marketplace location") set(CURA_MARKETPLACE_ROOT "" CACHE STRING "Alternative Marketplace location")
set(CURA_DIGITAL_FACTORY_URL "" CACHE STRING "Alternative Digital Factory location")
configure_file(${CMAKE_SOURCE_DIR}/cura.desktop.in ${CMAKE_BINARY_DIR}/cura.desktop @ONLY) configure_file(${CMAKE_SOURCE_DIR}/com.ultimaker.cura.desktop.in ${CMAKE_BINARY_DIR}/com.ultimaker.cura.desktop @ONLY)
configure_file(cura/CuraVersion.py.in CuraVersion.py @ONLY) configure_file(cura/CuraVersion.py.in CuraVersion.py @ONLY)
# FIXME: Remove the code for CMake <3.12 once we have switched over completely. # FIXME: The new FindPython3 finds the system's Python3.6 reather than the Python3.5 that we built for Cura's environment.
# FindPython3 is a new module since CMake 3.12. It deprecates FindPythonInterp and FindPythonLibs. The FindPython3 # So we're using the old method here, with FindPythonInterp for now.
# module is copied from the CMake repository here so in CMake <3.12 we can still use it. find_package(PythonInterp 3 REQUIRED)
if(${CMAKE_VERSION} VERSION_LESS 3.12)
# Use FindPythonInterp and FindPythonLibs for CMake <3.12
find_package(PythonInterp 3 REQUIRED)
set(Python3_EXECUTABLE ${PYTHON_EXECUTABLE}) set(Python3_EXECUTABLE ${PYTHON_EXECUTABLE})
set(Python3_VERSION ${PYTHON_VERSION_STRING})
set(Python3_VERSION_MAJOR ${PYTHON_VERSION_MAJOR})
set(Python3_VERSION_MINOR ${PYTHON_VERSION_MINOR})
set(Python3_VERSION_PATCH ${PYTHON_VERSION_PATCH})
else()
# Use FindPython3 for CMake >=3.12
find_package(Python3 REQUIRED COMPONENTS Interpreter Development)
endif()
set(Python3_VERSION ${PYTHON_VERSION_STRING})
set(Python3_VERSION_MAJOR ${PYTHON_VERSION_MAJOR})
set(Python3_VERSION_MINOR ${PYTHON_VERSION_MINOR})
set(Python3_VERSION_PATCH ${PYTHON_VERSION_PATCH})
if(NOT ${URANIUM_DIR} STREQUAL "") if(NOT ${URANIUM_DIR} STREQUAL "")
set(CMAKE_MODULE_PATH "${CMAKE_MODULE_PATH};${URANIUM_DIR}/cmake") set(CMAKE_MODULE_PATH "${CMAKE_MODULE_PATH};${URANIUM_DIR}/cmake")
@ -58,7 +53,9 @@ if(NOT ${URANIUM_SCRIPTS_DIR} STREQUAL "")
# Extract Strings # Extract Strings
add_custom_target(extract-messages ${URANIUM_SCRIPTS_DIR}/extract-messages ${CMAKE_SOURCE_DIR} cura) add_custom_target(extract-messages ${URANIUM_SCRIPTS_DIR}/extract-messages ${CMAKE_SOURCE_DIR} cura)
# Build Translations # Build Translations
CREATE_TRANSLATION_TARGETS() if(${GENERATE_TRANSLATIONS})
CREATE_TRANSLATION_TARGETS()
endif()
endif() endif()
@ -85,11 +82,11 @@ if(NOT APPLE AND NOT WIN32)
install(FILES ${CMAKE_BINARY_DIR}/CuraVersion.py install(FILES ${CMAKE_BINARY_DIR}/CuraVersion.py
DESTINATION lib${LIB_SUFFIX}/python${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/site-packages/cura) DESTINATION lib${LIB_SUFFIX}/python${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/site-packages/cura)
endif() endif()
install(FILES ${CMAKE_BINARY_DIR}/cura.desktop install(FILES ${CMAKE_BINARY_DIR}/com.ultimaker.cura.desktop
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
install(FILES ${CMAKE_SOURCE_DIR}/resources/images/cura-icon.png install(FILES ${CMAKE_SOURCE_DIR}/resources/images/cura-icon.png
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/128x128/apps/) DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/128x128/apps/)
install(FILES cura.appdata.xml install(FILES com.ultimaker.cura.appdata.xml
DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo) DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo)
install(FILES cura.sharedmimeinfo install(FILES cura.sharedmimeinfo
DESTINATION ${CMAKE_INSTALL_DATADIR}/mime/packages/ DESTINATION ${CMAKE_INSTALL_DATADIR}/mime/packages/

View file

@ -1,6 +1,8 @@
Cura Cura
==== ====
This is the new, shiny frontend for Cura. Check [daid/LegacyCura](https://github.com/daid/LegacyCura) for the legacy Cura that everyone knows and loves/hates. We re-worked the whole GUI code at Ultimaker, because the old code started to become unmaintainable. Ultimaker Cura is a state-of-the-art slicer application to prepare your 3D models for printing with a 3D printer. With hundreds of settings and hundreds of community-managed print profiles, Ultimaker Cura is sure to lead your next project to a success.
![Screenshot](screenshot.png)
Logging Issues Logging Issues
------------ ------------
@ -8,13 +10,13 @@ For crashes and similar issues, please attach the following information:
* (On Windows) The log as produced by dxdiag (start -> run -> dxdiag -> save output) * (On Windows) The log as produced by dxdiag (start -> run -> dxdiag -> save output)
* The Cura GUI log file, located at * The Cura GUI log file, located at
* `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\\<your username>\AppData\Roaming\cura\<Cura version>\cura.log` * `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
* `$USER/Library/Application Support/cura/<Cura version>/cura.log` (OSX) * `$HOME/Library/Application Support/cura/<Cura version>/cura.log` (OSX)
* `$USER/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux) * `$HOME/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux)
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
For additional support, you could also ask in the #cura channel on FreeNode IRC. For help with development, there is also the #cura-dev channel. For additional support, you could also ask in the [#cura channel](https://web.libera.chat/#cura) on [libera.chat](https://libera.chat/). For help with development, there is also the [#cura-dev channel](https://web.libera.chat/#cura-dev).
Dependencies Dependencies
------------ ------------
@ -24,9 +26,15 @@ Dependencies
* [PySerial](https://github.com/pyserial/pyserial) Only required for USB printing support. * [PySerial](https://github.com/pyserial/pyserial) Only required for USB printing support.
* [python-zeroconf](https://github.com/jstasiak/python-zeroconf) Only required to detect mDNS-enabled printers. * [python-zeroconf](https://github.com/jstasiak/python-zeroconf) Only required to detect mDNS-enabled printers.
For a list of required Python packages, with their recommended version, see `requirements.txt`.
This list is not exhaustive at the moment, please check the links in the next section for more details.
Build scripts Build scripts
------------- -------------
Please checkout [cura-build](https://github.com/Ultimaker/cura-build) for detailed building instructions. Please check out [cura-build](https://github.com/Ultimaker/cura-build) for detailed building instructions.
If you want to build the entire environment from scratch before building Cura as well, [cura-build-environment](https://github.com/Ultimaker/cura-build) might be a starting point before cura-build. (Again, see cura-build for more details.)
Running from Source Running from Source
------------- -------------

View file

@ -9,6 +9,8 @@
# form of "a;b;c" or "a,b,c". By default all plugins will be installed. # form of "a;b;c" or "a,b,c". By default all plugins will be installed.
# #
option(PRINT_PLUGIN_LIST "Should the list of plugins that are installed be printed?" ON)
# FIXME: Remove the code for CMake <3.12 once we have switched over completely. # FIXME: Remove the code for CMake <3.12 once we have switched over completely.
# FindPython3 is a new module since CMake 3.12. It deprecates FindPythonInterp and FindPythonLibs. The FindPython3 # FindPython3 is a new module since CMake 3.12. It deprecates FindPythonInterp and FindPythonLibs. The FindPython3
# module is copied from the CMake repository here so in CMake <3.12 we can still use it. # module is copied from the CMake repository here so in CMake <3.12 we can still use it.
@ -81,7 +83,9 @@ foreach(_plugin_json_path ${_plugin_json_list})
endif() endif()
if(_add_plugin) if(_add_plugin)
message(STATUS "[+] PLUGIN TO INSTALL: ${_rel_plugin_dir}") if(${PRINT_PLUGIN_LIST})
message(STATUS "[+] PLUGIN TO INSTALL: ${_rel_plugin_dir}")
endif()
get_filename_component(_rel_plugin_parent_dir ${_rel_plugin_dir} DIRECTORY) get_filename_component(_rel_plugin_parent_dir ${_rel_plugin_dir} DIRECTORY)
install(DIRECTORY ${_rel_plugin_dir} install(DIRECTORY ${_rel_plugin_dir}
DESTINATION lib${LIB_SUFFIX}/cura/${_rel_plugin_parent_dir} DESTINATION lib${LIB_SUFFIX}/cura/${_rel_plugin_parent_dir}
@ -90,7 +94,9 @@ foreach(_plugin_json_path ${_plugin_json_list})
) )
list(APPEND _install_plugin_list ${_plugin_dir}) list(APPEND _install_plugin_list ${_plugin_dir})
elseif(_is_no_install_plugin) elseif(_is_no_install_plugin)
message(STATUS "[-] PLUGIN TO REMOVE : ${_rel_plugin_dir}") if(${PRINT_PLUGIN_LIST})
message(STATUS "[-] PLUGIN TO REMOVE : ${_rel_plugin_dir}")
endif()
execute_process(COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/cmake/mod_bundled_packages_json.py execute_process(COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/cmake/mod_bundled_packages_json.py
-d ${CMAKE_CURRENT_SOURCE_DIR}/resources/bundled_packages -d ${CMAKE_CURRENT_SOURCE_DIR}/resources/bundled_packages
${_plugin_dir_name} ${_plugin_dir_name}

View file

@ -4,18 +4,11 @@
include(CTest) include(CTest)
include(CMakeParseArguments) include(CMakeParseArguments)
# FIXME: Remove the code for CMake <3.12 once we have switched over completely. # FIXME: The new FindPython3 finds the system's Python3.6 reather than the Python3.5 that we built for Cura's environment.
# FindPython3 is a new module since CMake 3.12. It deprecates FindPythonInterp and FindPythonLibs. The FindPython3 # So we're using the old method here, with FindPythonInterp for now.
# module is copied from the CMake repository here so in CMake <3.12 we can still use it. find_package(PythonInterp 3 REQUIRED)
if(${CMAKE_VERSION} VERSION_LESS 3.12)
# Use FindPythonInterp and FindPythonLibs for CMake <3.12
find_package(PythonInterp 3 REQUIRED)
set(Python3_EXECUTABLE ${PYTHON_EXECUTABLE}) set(Python3_EXECUTABLE ${PYTHON_EXECUTABLE})
else()
# Use FindPython3 for CMake >=3.12
find_package(Python3 REQUIRED COMPONENTS Interpreter Development)
endif()
add_custom_target(test-verbose COMMAND ${CMAKE_CTEST_COMMAND} --verbose) add_custom_target(test-verbose COMMAND ${CMAKE_CTEST_COMMAND} --verbose)
@ -56,6 +49,14 @@ function(cura_add_test)
endif() endif()
endfunction() endfunction()
#Add code style test.
add_test(
NAME "code-style"
COMMAND ${Python3_EXECUTABLE} run_mypy.py
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
)
#Add test for import statements which are not compatible with all builds #Add test for import statements which are not compatible with all builds
add_test( add_test(
NAME "invalid-imports" NAME "invalid-imports"
@ -74,13 +75,6 @@ foreach(_plugin ${_plugins})
endif() endif()
endforeach() endforeach()
#Add code style test.
add_test(
NAME "code-style"
COMMAND ${Python3_EXECUTABLE} run_mypy.py
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
)
#Add test for whether the shortcut alt-keys are unique in every translation. #Add test for whether the shortcut alt-keys are unique in every translation.
add_test( add_test(
NAME "shortcut-keys" NAME "shortcut-keys"

View file

@ -11,11 +11,13 @@ import os
import sys import sys
## Finds all JSON files in the given directory recursively and returns a list of those files in absolute paths.
#
# \param work_dir The directory to look for JSON files recursively.
# \return A list of JSON files in absolute paths that are found in the given directory.
def find_json_files(work_dir: str) -> list: def find_json_files(work_dir: str) -> list:
"""Finds all JSON files in the given directory recursively and returns a list of those files in absolute paths.
:param work_dir: The directory to look for JSON files recursively.
:return: A list of JSON files in absolute paths that are found in the given directory.
"""
json_file_list = [] json_file_list = []
for root, dir_names, file_names in os.walk(work_dir): for root, dir_names, file_names in os.walk(work_dir):
for file_name in file_names: for file_name in file_names:
@ -24,12 +26,14 @@ def find_json_files(work_dir: str) -> list:
return json_file_list return json_file_list
## Removes the given entries from the given JSON file. The file will modified in-place.
#
# \param file_path The JSON file to modify.
# \param entries A list of strings as entries to remove.
# \return None
def remove_entries_from_json_file(file_path: str, entries: list) -> None: def remove_entries_from_json_file(file_path: str, entries: list) -> None:
"""Removes the given entries from the given JSON file. The file will modified in-place.
:param file_path: The JSON file to modify.
:param entries: A list of strings as entries to remove.
:return: None
"""
try: try:
with open(file_path, "r", encoding = "utf-8") as f: with open(file_path, "r", encoding = "utf-8") as f:
package_dict = json.load(f, object_hook = collections.OrderedDict) package_dict = json.load(f, object_hook = collections.OrderedDict)

View file

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

View file

@ -1,15 +1,16 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, Dict, TYPE_CHECKING from datetime import datetime
from typing import Optional, Dict, TYPE_CHECKING, Callable
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS
from UM.i18n import i18nCatalog from UM.Logger import Logger
from UM.Message import Message from UM.Message import Message
from cura import UltimakerCloudAuthentication from UM.i18n import i18nCatalog
from cura.OAuth2.AuthorizationService import AuthorizationService from cura.OAuth2.AuthorizationService import AuthorizationService
from cura.OAuth2.Models import OAuth2Settings from cura.OAuth2.Models import OAuth2Settings
from cura.UltimakerCloud import UltimakerCloudConstants
if TYPE_CHECKING: if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
@ -17,38 +18,73 @@ if TYPE_CHECKING:
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")
## The account API provides a version-proof bridge to use Ultimaker Accounts class SyncState:
# """QML: Cura.AccountSyncState"""
# Usage: SYNCING = 0
# ``from cura.API import CuraAPI SUCCESS = 1
# api = CuraAPI() ERROR = 2
# api.account.login() IDLE = 3
# api.account.logout()
# api.account.userProfile # Who is logged in``
#
class Account(QObject): class Account(QObject):
# Signal emitted when user logged in or out. """The account API provides a version-proof bridge to use Ultimaker Accounts
Usage:
.. code-block:: python
from cura.API import CuraAPI
api = CuraAPI()
api.account.login()
api.account.logout()
api.account.userProfile # Who is logged in
"""
# The interval in which sync services are automatically triggered
SYNC_INTERVAL = 60.0 # seconds
Q_ENUMS(SyncState)
loginStateChanged = pyqtSignal(bool) loginStateChanged = pyqtSignal(bool)
"""Signal emitted when user logged in or out"""
accessTokenChanged = pyqtSignal() accessTokenChanged = pyqtSignal()
syncRequested = pyqtSignal()
"""Sync services may connect to this signal to receive sync triggers.
Services should be resilient to receiving a signal while they are still syncing,
either by ignoring subsequent signals or restarting a sync.
See setSyncState() for providing user feedback on the state of your service.
"""
lastSyncDateTimeChanged = pyqtSignal()
syncStateChanged = pyqtSignal(int) # because SyncState is an int Enum
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: def __init__(self, application: "CuraApplication", parent = None) -> None:
super().__init__(parent) super().__init__(parent)
self._application = application self._application = application
self._new_cloud_printers_detected = False
self._error_message = None # type: Optional[Message] self._error_message = None # type: Optional[Message]
self._logged_in = False self._logged_in = False
self._sync_state = SyncState.IDLE
self._manual_sync_enabled = False
self._update_packages_enabled = False
self._update_packages_action = None # type: Optional[Callable]
self._last_sync_str = "-"
self._callback_port = 32118 self._callback_port = 32118
self._oauth_root = UltimakerCloudAuthentication.CuraCloudAccountAPIRoot self._oauth_root = UltimakerCloudConstants.CuraCloudAccountAPIRoot
self._oauth_settings = OAuth2Settings( self._oauth_settings = OAuth2Settings(
OAUTH_SERVER_URL= self._oauth_root, OAUTH_SERVER_URL= self._oauth_root,
CALLBACK_PORT=self._callback_port, CALLBACK_PORT=self._callback_port,
CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port), CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port),
CLIENT_ID="um----------------------------ultimaker_cura", CLIENT_ID="um----------------------------ultimaker_cura",
CLIENT_SCOPES="account.user.read drive.backup.read drive.backup.write packages.download " CLIENT_SCOPES=self.CLIENT_SCOPES,
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write "
"cura.printjob.read cura.printjob.write cura.mesh.read cura.mesh.write",
AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data", AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data",
AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root), AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root),
AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root) AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root)
@ -56,6 +92,16 @@ class Account(QObject):
self._authorization_service = AuthorizationService(self._oauth_settings) self._authorization_service = AuthorizationService(self._oauth_settings)
# Create a timer for automatic account sync
self._update_timer = QTimer()
self._update_timer.setInterval(int(self.SYNC_INTERVAL * 1000))
# The timer is restarted explicitly after an update was processed. This prevents 2 concurrent updates
self._update_timer.setSingleShot(True)
self._update_timer.timeout.connect(self.sync)
self._sync_services = {} # type: Dict[str, int]
"""contains entries "service_name" : SyncState"""
def initialize(self) -> None: def initialize(self) -> None:
self._authorization_service.initialize(self._application.getPreferences()) self._authorization_service.initialize(self._application.getPreferences())
self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged) self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged)
@ -63,12 +109,64 @@ class Account(QObject):
self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged) self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged)
self._authorization_service.loadAuthDataFromPreferences() self._authorization_service.loadAuthDataFromPreferences()
@pyqtProperty(int, notify=syncStateChanged)
def syncState(self):
return self._sync_state
def setSyncState(self, service_name: str, state: int) -> None:
""" Can be used to register sync services and update account sync states
Contract: A sync service is expected exit syncing state in all cases, within reasonable time
Example: `setSyncState("PluginSyncService", SyncState.SYNCING)`
:param service_name: A unique name for your service, such as `plugins` or `backups`
:param state: One of SyncState
"""
prev_state = self._sync_state
self._sync_services[service_name] = state
if any(val == SyncState.SYNCING for val in self._sync_services.values()):
self._sync_state = SyncState.SYNCING
self._setManualSyncEnabled(False)
elif any(val == SyncState.ERROR for val in self._sync_services.values()):
self._sync_state = SyncState.ERROR
self._setManualSyncEnabled(True)
else:
self._sync_state = SyncState.SUCCESS
self._setManualSyncEnabled(False)
if self._sync_state != prev_state:
self.syncStateChanged.emit(self._sync_state)
if self._sync_state == SyncState.SUCCESS:
self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M")
self.lastSyncDateTimeChanged.emit()
if self._sync_state != SyncState.SYNCING:
# schedule new auto update after syncing completed (for whatever reason)
if not self._update_timer.isActive():
self._update_timer.start()
def setUpdatePackagesAction(self, action: Callable) -> None:
""" Set the callback which will be invoked when the user clicks the update packages button
Should be invoked after your service sets the sync state to SYNCING and before setting the
sync state to SUCCESS.
Action will be reset to None when the next sync starts
"""
self._update_packages_action = action
self._update_packages_enabled = True
self.updatePackagesEnabledChanged.emit(self._update_packages_enabled)
def _onAccessTokenChanged(self): def _onAccessTokenChanged(self):
self.accessTokenChanged.emit() self.accessTokenChanged.emit()
## Returns a boolean indicating whether the given authentication is applied against staging or not.
@property @property
def is_staging(self) -> bool: def is_staging(self) -> bool:
"""Indication whether the given authentication is applied against staging or not."""
return "staging" in self._oauth_root return "staging" in self._oauth_root
@pyqtProperty(bool, notify=loginStateChanged) @pyqtProperty(bool, notify=loginStateChanged)
@ -79,22 +177,67 @@ class Account(QObject):
if error_message: if error_message:
if self._error_message: if self._error_message:
self._error_message.hide() self._error_message.hide()
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._error_message.show()
self._logged_in = False self._logged_in = False
self.loginStateChanged.emit(False) self.loginStateChanged.emit(False)
if self._update_timer.isActive():
self._update_timer.stop()
return return
if self._logged_in != logged_in: if self._logged_in != logged_in:
self._logged_in = logged_in self._logged_in = logged_in
self.loginStateChanged.emit(logged_in) self.loginStateChanged.emit(logged_in)
if logged_in:
self._setManualSyncEnabled(False)
self._sync()
else:
if self._update_timer.isActive():
self._update_timer.stop()
def _sync(self) -> None:
"""Signals all sync services to start syncing
This can be considered a forced sync: even when a
sync is currently running, a sync will be requested.
"""
self._update_packages_action = None
self._update_packages_enabled = False
self.updatePackagesEnabledChanged.emit(self._update_packages_enabled)
if self._update_timer.isActive():
self._update_timer.stop()
elif self._sync_state == SyncState.SYNCING:
Logger.debug("Starting a new sync while previous sync was not completed")
self.syncRequested.emit()
def _setManualSyncEnabled(self, enabled: bool) -> None:
if self._manual_sync_enabled != enabled:
self._manual_sync_enabled = enabled
self.manualSyncEnabledChanged.emit(enabled)
@pyqtSlot() @pyqtSlot()
def login(self) -> None: @pyqtSlot(bool)
def login(self, force_logout_before_login: bool = False) -> None:
"""
Initializes the login process. If the user is logged in already and force_logout_before_login is true, Cura will
logout from the account before initiating the authorization flow. If the user is logged in and
force_logout_before_login is false, the function will return, as there is nothing to do.
:param force_logout_before_login: Optional boolean parameter
:return: None
"""
if self._logged_in: if self._logged_in:
# Nothing to do, user already logged in. if force_logout_before_login:
return self.logout()
self._authorization_service.startAuthorizationFlow() else:
# Nothing to do, user already logged in.
return
self._authorization_service.startAuthorizationFlow(force_logout_before_login)
@pyqtProperty(str, notify=loginStateChanged) @pyqtProperty(str, notify=loginStateChanged)
def userName(self): def userName(self):
@ -114,15 +257,44 @@ class Account(QObject):
def accessToken(self) -> Optional[str]: def accessToken(self) -> Optional[str]:
return self._authorization_service.getAccessToken() return self._authorization_service.getAccessToken()
# Get the profile of the logged in user
# @returns None if no user is logged in, a dict containing user_id, username and profile_image_url
@pyqtProperty("QVariantMap", notify = loginStateChanged) @pyqtProperty("QVariantMap", notify = loginStateChanged)
def userProfile(self) -> Optional[Dict[str, Optional[str]]]: def userProfile(self) -> Optional[Dict[str, Optional[str]]]:
"""None if no user is logged in otherwise the logged in user as a dict containing containing user_id, username and profile_image_url """
user_profile = self._authorization_service.getUserProfile() user_profile = self._authorization_service.getUserProfile()
if not user_profile: if not user_profile:
return None return None
return user_profile.__dict__ return user_profile.__dict__
@pyqtProperty(str, notify=lastSyncDateTimeChanged)
def lastSyncDateTime(self) -> str:
return self._last_sync_str
@pyqtProperty(bool, notify=manualSyncEnabledChanged)
def manualSyncEnabled(self) -> bool:
return self._manual_sync_enabled
@pyqtProperty(bool, notify=updatePackagesEnabledChanged)
def updatePackagesEnabled(self) -> bool:
return self._update_packages_enabled
@pyqtSlot()
@pyqtSlot(bool)
def sync(self, user_initiated: bool = False) -> None:
if user_initiated:
self._setManualSyncEnabled(False)
self._sync()
@pyqtSlot()
def onUpdatePackagesClicked(self) -> None:
if self._update_packages_action is not None:
self._update_packages_action()
@pyqtSlot()
def popupOpened(self) -> None:
self._setManualSyncEnabled(True)
@pyqtSlot() @pyqtSlot()
def logout(self) -> None: def logout(self) -> None:
if not self._logged_in: if not self._logged_in:

View file

@ -8,28 +8,37 @@ if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
## The back-ups API provides a version-proof bridge between Cura's
# BackupManager and plug-ins that hook into it.
#
# Usage:
# ``from cura.API import CuraAPI
# api = CuraAPI()
# api.backups.createBackup()
# api.backups.restoreBackup(my_zip_file, {"cura_release": "3.1"})``
class Backups: class Backups:
"""The back-ups API provides a version-proof bridge between Cura's
BackupManager and plug-ins that hook into it.
Usage:
.. code-block:: python
from cura.API import CuraAPI
api = CuraAPI()
api.backups.createBackup()
api.backups.restoreBackup(my_zip_file, {"cura_release": "3.1"})
"""
def __init__(self, application: "CuraApplication") -> None: def __init__(self, application: "CuraApplication") -> None:
self.manager = BackupsManager(application) self.manager = BackupsManager(application)
## Create a new back-up using the BackupsManager.
# \return Tuple containing a ZIP file with the back-up data and a dict
# with metadata about the back-up.
def createBackup(self) -> Tuple[Optional[bytes], Optional[Dict[str, Any]]]: def createBackup(self) -> Tuple[Optional[bytes], Optional[Dict[str, Any]]]:
"""Create a new back-up using the BackupsManager.
:return: Tuple containing a ZIP file with the back-up data and a dict with metadata about the back-up.
"""
return self.manager.createBackup() return self.manager.createBackup()
## Restore a back-up using the BackupsManager.
# \param zip_file A ZIP file containing the actual back-up data.
# \param meta_data Some metadata needed for restoring a back-up, like the
# Cura version number.
def restoreBackup(self, zip_file: bytes, meta_data: Dict[str, Any]) -> None: def restoreBackup(self, zip_file: bytes, meta_data: Dict[str, Any]) -> None:
"""Restore a back-up using the BackupsManager.
:param zip_file: A ZIP file containing the actual back-up data.
:param meta_data: Some metadata needed for restoring a back-up, like the Cura version number.
"""
return self.manager.restoreBackup(zip_file, meta_data) return self.manager.restoreBackup(zip_file, meta_data)

View file

@ -0,0 +1,41 @@
from typing import Optional
from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
class ConnectionStatus(QObject):
"""Provides an estimation of whether internet is reachable
Estimation is updated with every request through HttpRequestManager.
Acts as a proxy to HttpRequestManager.internetReachableChanged without
exposing the HttpRequestManager in its entirety.
"""
__instance = None # type: Optional[ConnectionStatus]
internetReachableChanged = pyqtSignal()
@classmethod
def getInstance(cls, *args, **kwargs) -> "ConnectionStatus":
if cls.__instance is None:
cls.__instance = cls(*args, **kwargs)
return cls.__instance
def __init__(self, parent: Optional["QObject"] = None) -> None:
super().__init__(parent)
manager = HttpRequestManager.getInstance()
self._is_internet_reachable = manager.isInternetReachable # type: bool
manager.internetReachableChanged.connect(self._onInternetReachableChanged)
@pyqtProperty(bool, notify = internetReachableChanged)
def isInternetReachable(self) -> bool:
return self._is_internet_reachable
def _onInternetReachableChanged(self, reachable: bool):
if reachable != self._is_internet_reachable:
self._is_internet_reachable = reachable
self.internetReachableChanged.emit()

View file

@ -7,32 +7,43 @@ if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
## The Interface.Settings API provides a version-proof bridge between Cura's
# (currently) sidebar UI and plug-ins that hook into it.
#
# Usage:
# ``from cura.API import CuraAPI
# api = CuraAPI()
# api.interface.settings.getContextMenuItems()
# data = {
# "name": "My Plugin Action",
# "iconName": "my-plugin-icon",
# "actions": my_menu_actions,
# "menu_item": MyPluginAction(self)
# }
# api.interface.settings.addContextMenuItem(data)``
class Settings: class Settings:
"""The Interface.Settings API provides a version-proof bridge
between Cura's
(currently) sidebar UI and plug-ins that hook into it.
Usage:
.. code-block:: python
from cura.API import CuraAPI
api = CuraAPI()
api.interface.settings.getContextMenuItems()
data = {
"name": "My Plugin Action",
"iconName": "my-plugin-icon",
"actions": my_menu_actions,
"menu_item": MyPluginAction(self)
}
api.interface.settings.addContextMenuItem(data)
"""
def __init__(self, application: "CuraApplication") -> None: def __init__(self, application: "CuraApplication") -> None:
self.application = application self.application = application
## Add items to the sidebar context menu.
# \param menu_item dict containing the menu item to add.
def addContextMenuItem(self, menu_item: dict) -> None: def addContextMenuItem(self, menu_item: dict) -> None:
"""Add items to the sidebar context menu.
:param menu_item: dict containing the menu item to add.
"""
self.application.addSidebarCustomMenuItem(menu_item) self.application.addSidebarCustomMenuItem(menu_item)
## Get all custom items currently added to the sidebar context menu.
# \return List containing all custom context menu items.
def getContextMenuItems(self) -> list: def getContextMenuItems(self) -> list:
"""Get all custom items currently added to the sidebar context menu.
:return: List containing all custom context menu items.
"""
return self.application.getSidebarCustomMenuItems() return self.application.getSidebarCustomMenuItems()

View file

@ -9,18 +9,22 @@ if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
## The Interface class serves as a common root for the specific API
# methods for each interface element.
#
# Usage:
# ``from cura.API import CuraAPI
# api = CuraAPI()
# api.interface.settings.addContextMenuItem()
# api.interface.viewport.addOverlay() # Not implemented, just a hypothetical
# api.interface.toolbar.getToolButtonCount() # Not implemented, just a hypothetical
# # etc.``
class Interface: class Interface:
"""The Interface class serves as a common root for the specific API
methods for each interface element.
Usage:
.. code-block:: python
from cura.API import CuraAPI
api = CuraAPI()
api.interface.settings.addContextMenuItem()
api.interface.viewport.addOverlay() # Not implemented, just a hypothetical
api.interface.toolbar.getToolButtonCount() # Not implemented, just a hypothetical
# etc
"""
def __init__(self, application: "CuraApplication") -> None: def __init__(self, application: "CuraApplication") -> None:
# API methods specific to the settings portion of the UI # API methods specific to the settings portion of the UI

View file

@ -5,6 +5,7 @@ from typing import Optional, TYPE_CHECKING
from PyQt5.QtCore import QObject, pyqtProperty from PyQt5.QtCore import QObject, pyqtProperty
from cura.API.Backups import Backups from cura.API.Backups import Backups
from cura.API.ConnectionStatus import ConnectionStatus
from cura.API.Interface import Interface from cura.API.Interface import Interface
from cura.API.Account import Account from cura.API.Account import Account
@ -12,13 +13,14 @@ if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
## The official Cura API that plug-ins can use to interact with Cura.
#
# Python does not technically prevent talking to other classes as well, but
# this API provides a version-safe interface with proper deprecation warnings
# etc. Usage of any other methods than the ones provided in this API can cause
# plug-ins to be unstable.
class CuraAPI(QObject): class CuraAPI(QObject):
"""The official Cura API that plug-ins can use to interact with Cura.
Python does not technically prevent talking to other classes as well, but this API provides a version-safe
interface with proper deprecation warnings etc. Usage of any other methods than the ones provided in this API can
cause plug-ins to be unstable.
"""
# For now we use the same API version to be consistent. # For now we use the same API version to be consistent.
__instance = None # type: "CuraAPI" __instance = None # type: "CuraAPI"
@ -39,12 +41,12 @@ class CuraAPI(QObject):
def __init__(self, application: Optional["CuraApplication"] = None) -> None: def __init__(self, application: Optional["CuraApplication"] = None) -> None:
super().__init__(parent = CuraAPI._application) super().__init__(parent = CuraAPI._application)
# Accounts API
self._account = Account(self._application) self._account = Account(self._application)
# Backups API
self._backups = Backups(self._application) self._backups = Backups(self._application)
self._connectionStatus = ConnectionStatus()
# Interface API # Interface API
self._interface = Interface(self._application) self._interface = Interface(self._application)
@ -53,12 +55,22 @@ class CuraAPI(QObject):
@pyqtProperty(QObject, constant = True) @pyqtProperty(QObject, constant = True)
def account(self) -> "Account": def account(self) -> "Account":
"""Accounts API"""
return self._account return self._account
@pyqtProperty(QObject, constant = True)
def connectionStatus(self) -> "ConnectionStatus":
return self._connectionStatus
@property @property
def backups(self) -> "Backups": def backups(self) -> "Backups":
"""Backups API"""
return self._backups return self._backups
@property @property
def interface(self) -> "Interface": def interface(self) -> "Interface":
"""Interface API"""
return self._interface return self._interface

View file

@ -13,7 +13,7 @@ DEFAULT_CURA_DEBUG_MODE = False
# Each release has a fixed SDK version coupled with it. It doesn't make sense to make it configurable because, for # Each release has a fixed SDK version coupled with it. It doesn't make sense to make it configurable because, for
# example Cura 3.2 with SDK version 6.1 will not work. So the SDK version is hard-coded here and left out of the # example Cura 3.2 with SDK version 6.1 will not work. So the SDK version is hard-coded here and left out of the
# CuraVersion.py.in template. # CuraVersion.py.in template.
CuraSDKVersion = "7.1.0" CuraSDKVersion = "7.6.0"
try: try:
from cura.CuraVersion import CuraAppName # type: ignore from cura.CuraVersion import CuraAppName # type: ignore
@ -46,6 +46,10 @@ except ImportError:
# Various convenience flags indicating what kind of Cura build it is. # Various convenience flags indicating what kind of Cura build it is.
__ENTERPRISE_VERSION_TYPE = "enterprise" __ENTERPRISE_VERSION_TYPE = "enterprise"
IsEnterpriseVersion = CuraBuildType.lower() == __ENTERPRISE_VERSION_TYPE IsEnterpriseVersion = CuraBuildType.lower() == __ENTERPRISE_VERSION_TYPE
IsAlternateVersion = CuraBuildType.lower() not in [DEFAULT_CURA_BUILD_TYPE, __ENTERPRISE_VERSION_TYPE]
# NOTE: IsAlternateVersion is to make it possibile to have 'non-numbered' versions, at least as presented to the user.
# (Internally, it'll still have some sort of version-number, but the user is never meant to see it in the GUI).
# Warning: This will also change (some of) the icons/splash-screen to the 'work in progress' alternatives!
try: try:
from cura.CuraVersion import CuraAppDisplayName # type: ignore from cura.CuraVersion import CuraAppDisplayName # type: ignore

View file

@ -1,7 +1,8 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import List, Optional from typing import Optional
from UM.Decorators import deprecated
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Logger import Logger from UM.Logger import Logger
from UM.Math.Polygon import Polygon from UM.Math.Polygon import Polygon
@ -16,21 +17,24 @@ from collections import namedtuple
import numpy import numpy
import copy import copy
## Return object for bestSpot
LocationSuggestion = namedtuple("LocationSuggestion", ["x", "y", "penalty_points", "priority"]) LocationSuggestion = namedtuple("LocationSuggestion", ["x", "y", "penalty_points", "priority"])
"""Return object for bestSpot"""
## The Arrange classed is used together with ShapeArray. Use it to find
# good locations for objects that you try to put on a build place.
# Different priority schemes can be defined so it alters the behavior while using
# the same logic.
#
# Note: Make sure the scale is the same between ShapeArray objects and the Arrange instance.
class Arrange: class Arrange:
"""
The Arrange classed is used together with :py:class:`cura.Arranging.ShapeArray.ShapeArray`. Use it to find good locations for objects that you try to put
on a build place. Different priority schemes can be defined so it alters the behavior while using the same logic.
.. note::
Make sure the scale is the same between :py:class:`cura.Arranging.ShapeArray.ShapeArray` objects and the :py:class:`cura.Arranging.Arrange.Arrange` instance.
"""
build_volume = None # type: Optional[BuildVolume] build_volume = None # type: Optional[BuildVolume]
def __init__(self, x, y, offset_x, offset_y, scale= 0.5): @deprecated("Use the functions in Nest2dArrange instead", "4.8")
def __init__(self, x, y, offset_x, offset_y, scale = 0.5):
self._scale = scale # convert input coordinates to arrange coordinates self._scale = scale # convert input coordinates to arrange coordinates
world_x, world_y = int(x * self._scale), int(y * self._scale) world_x, world_y = int(x * self._scale), int(y * self._scale)
self._shape = (world_y, world_x) self._shape = (world_y, world_x)
@ -42,14 +46,22 @@ class Arrange:
self._last_priority = 0 self._last_priority = 0
self._is_empty = True self._is_empty = True
## Helper to create an Arranger instance
#
# Either fill in scene_root and create will find all sliceable nodes by itself,
# or use fixed_nodes to provide the nodes yourself.
# \param scene_root Root for finding all scene nodes
# \param fixed_nodes Scene nodes to be placed
@classmethod @classmethod
def create(cls, scene_root = None, fixed_nodes = None, scale = 0.5, x = 350, y = 250, min_offset = 8): @deprecated("Use the functions in Nest2dArrange instead", "4.8")
def create(cls, scene_root = None, fixed_nodes = None, scale = 0.5, x = 350, y = 250, min_offset = 8) -> "Arrange":
"""Helper to create an :py:class:`cura.Arranging.Arrange.Arrange` instance
Either fill in scene_root and create will find all sliceable nodes by itself, or use fixed_nodes to provide the
nodes yourself.
:param scene_root: Root for finding all scene nodes default = None
:param fixed_nodes: Scene nodes to be placed default = None
:param scale: default = 0.5
:param x: default = 350
:param y: default = 250
:param min_offset: default = 8
"""
arranger = Arrange(x, y, x // 2, y // 2, scale = scale) arranger = Arrange(x, y, x // 2, y // 2, scale = scale)
arranger.centerFirst() arranger.centerFirst()
@ -71,8 +83,11 @@ class Arrange:
# After scaling (like up to 0.1 mm) the node might not have points # After scaling (like up to 0.1 mm) the node might not have points
if not points.size: if not points.size:
continue continue
try:
shape_arr = ShapeArray.fromPolygon(points, scale = scale) shape_arr = ShapeArray.fromPolygon(points, scale = scale)
except ValueError:
Logger.logException("w", "Unable to create polygon")
continue
arranger.place(0, 0, shape_arr) arranger.place(0, 0, shape_arr)
# If a build volume was set, add the disallowed areas # If a build volume was set, add the disallowed areas
@ -84,16 +99,22 @@ class Arrange:
arranger.place(0, 0, shape_arr, update_empty = False) arranger.place(0, 0, shape_arr, update_empty = False)
return arranger return arranger
## This resets the optimization for finding location based on size
def resetLastPriority(self): def resetLastPriority(self):
"""This resets the optimization for finding location based on size"""
self._last_priority = 0 self._last_priority = 0
## Find placement for a node (using offset shape) and place it (using hull shape) @deprecated("Use the functions in Nest2dArrange instead", "4.8")
# return the nodes that should be placed def findNodePlacement(self, node: SceneNode, offset_shape_arr: ShapeArray, hull_shape_arr: ShapeArray, step = 1) -> bool:
# \param node """Find placement for a node (using offset shape) and place it (using hull shape)
# \param offset_shape_arr ShapeArray with offset, for placing the shape
# \param hull_shape_arr ShapeArray without offset, used to find location :param node: The node to be placed
def findNodePlacement(self, node: SceneNode, offset_shape_arr: ShapeArray, hull_shape_arr: ShapeArray, step = 1): :param offset_shape_arr: shape array with offset, for placing the shape
:param hull_shape_arr: shape array without offset, used to find location
:param step: default = 1
:return: the nodes that should be placed
"""
best_spot = self.bestSpot( best_spot = self.bestSpot(
hull_shape_arr, start_prio = self._last_priority, step = step) hull_shape_arr, start_prio = self._last_priority, step = step)
x, y = best_spot.x, best_spot.y x, y = best_spot.x, best_spot.y
@ -119,29 +140,32 @@ class Arrange:
node.setPosition(Vector(200, center_y, 100)) node.setPosition(Vector(200, center_y, 100))
return found_spot return found_spot
## Fill priority, center is best. Lower value is better
# This is a strategy for the arranger.
def centerFirst(self): def centerFirst(self):
"""Fill priority, center is best. Lower value is better. """
# Square distance: creates a more round shape # Square distance: creates a more round shape
self._priority = numpy.fromfunction( self._priority = numpy.fromfunction(
lambda j, i: (self._offset_x - i) ** 2 + (self._offset_y - j) ** 2, self._shape, dtype=numpy.int32) lambda j, i: (self._offset_x - i) ** 2 + (self._offset_y - j) ** 2, self._shape, dtype=numpy.int32)
self._priority_unique_values = numpy.unique(self._priority) self._priority_unique_values = numpy.unique(self._priority)
self._priority_unique_values.sort() self._priority_unique_values.sort()
## Fill priority, back is best. Lower value is better
# This is a strategy for the arranger.
def backFirst(self): def backFirst(self):
"""Fill priority, back is best. Lower value is better """
self._priority = numpy.fromfunction( self._priority = numpy.fromfunction(
lambda j, i: 10 * j + abs(self._offset_x - i), self._shape, dtype=numpy.int32) lambda j, i: 10 * j + abs(self._offset_x - i), self._shape, dtype=numpy.int32)
self._priority_unique_values = numpy.unique(self._priority) self._priority_unique_values = numpy.unique(self._priority)
self._priority_unique_values.sort() self._priority_unique_values.sort()
## Return the amount of "penalty points" for polygon, which is the sum of priority def checkShape(self, x, y, shape_arr) -> Optional[numpy.ndarray]:
# None if occupied """Return the amount of "penalty points" for polygon, which is the sum of priority
# \param x x-coordinate to check shape
# \param y y-coordinate :param x: x-coordinate to check shape
# \param shape_arr the ShapeArray object to place :param y: y-coordinate to check shape
def checkShape(self, x, y, shape_arr): :param shape_arr: the shape array object to place
:return: None if occupied
"""
x = int(self._scale * x) x = int(self._scale * x)
y = int(self._scale * y) y = int(self._scale * y)
offset_x = x + self._offset_x + shape_arr.offset_x offset_x = x + self._offset_x + shape_arr.offset_x
@ -165,12 +189,15 @@ class Arrange:
offset_x:offset_x + shape_arr.arr.shape[1]] offset_x:offset_x + shape_arr.arr.shape[1]]
return numpy.sum(prio_slice[numpy.where(shape_arr.arr == 1)]) return numpy.sum(prio_slice[numpy.where(shape_arr.arr == 1)])
## Find "best" spot for ShapeArray def bestSpot(self, shape_arr, start_prio = 0, step = 1) -> LocationSuggestion:
# Return namedtuple with properties x, y, penalty_points, priority. """Find "best" spot for ShapeArray
# \param shape_arr ShapeArray
# \param start_prio Start with this priority value (and skip the ones before) :param shape_arr: shape array
# \param step Slicing value, higher = more skips = faster but less accurate :param start_prio: Start with this priority value (and skip the ones before)
def bestSpot(self, shape_arr, start_prio = 0, step = 1): :param step: Slicing value, higher = more skips = faster but less accurate
:return: namedtuple with properties x, y, penalty_points, priority.
"""
start_idx_list = numpy.where(self._priority_unique_values == start_prio) start_idx_list = numpy.where(self._priority_unique_values == start_prio)
if start_idx_list: if start_idx_list:
try: try:
@ -179,6 +206,7 @@ class Arrange:
start_idx = 0 start_idx = 0
else: else:
start_idx = 0 start_idx = 0
priority = 0
for priority in self._priority_unique_values[start_idx::step]: for priority in self._priority_unique_values[start_idx::step]:
tryout_idx = numpy.where(self._priority == priority) tryout_idx = numpy.where(self._priority == priority)
for idx in range(len(tryout_idx[0])): for idx in range(len(tryout_idx[0])):
@ -192,13 +220,17 @@ class Arrange:
return LocationSuggestion(x = projected_x, y = projected_y, penalty_points = penalty_points, priority = priority) return LocationSuggestion(x = projected_x, y = projected_y, penalty_points = penalty_points, priority = priority)
return LocationSuggestion(x = None, y = None, penalty_points = None, priority = priority) # No suitable location found :-( return LocationSuggestion(x = None, y = None, penalty_points = None, priority = priority) # No suitable location found :-(
## Place the object.
# Marks the locations in self._occupied and self._priority
# \param x x-coordinate
# \param y y-coordinate
# \param shape_arr ShapeArray object
# \param update_empty updates the _is_empty, used when adding disallowed areas
def place(self, x, y, shape_arr, update_empty = True): def place(self, x, y, shape_arr, update_empty = True):
"""Place the object.
Marks the locations in self._occupied and self._priority
:param x:
:param y:
:param shape_arr:
:param update_empty: updates the _is_empty, used when adding disallowed areas
"""
x = int(self._scale * x) x = int(self._scale * x)
y = int(self._scale * y) y = int(self._scale * y)
offset_x = x + self._offset_x + shape_arr.offset_x offset_x = x + self._offset_x + shape_arr.offset_x

View file

@ -18,8 +18,9 @@ from cura.Arranging.ShapeArray import ShapeArray
from typing import List from typing import List
## Do arrangements on multiple build plates (aka builtiplexer)
class ArrangeArray: class ArrangeArray:
"""Do arrangements on multiple build plates (aka builtiplexer)"""
def __init__(self, x: int, y: int, fixed_nodes: List[SceneNode]) -> None: def __init__(self, x: int, y: int, fixed_nodes: List[SceneNode]) -> None:
self._x = x self._x = x
self._y = y self._y = y
@ -146,6 +147,8 @@ class ArrangeObjectsAllBuildPlatesJob(Job):
status_message.hide() status_message.hide()
if not found_solution_for_all: 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"), no_full_solution_message = Message(i18n_catalog.i18nc("@info:status",
title = i18n_catalog.i18nc("@info:title", "Can't Find Location")) "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() no_full_solution_message.show()

View file

@ -1,23 +1,17 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import List
from UM.Application import Application from UM.Application import Application
from UM.Job import Job from UM.Job import Job
from UM.Scene.SceneNode import SceneNode
from UM.Math.Vector import Vector
from UM.Operations.TranslateOperation import TranslateOperation
from UM.Operations.GroupedOperation import GroupedOperation
from UM.Logger import Logger from UM.Logger import Logger
from UM.Message import Message from UM.Message import Message
from UM.Scene.SceneNode import SceneNode
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from cura.Arranging.Nest2DArrange import arrange
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")
from cura.Scene.ZOffsetDecorator import ZOffsetDecorator
from cura.Arranging.Arrange import Arrange
from cura.Arranging.ShapeArray import ShapeArray
from typing import List
class ArrangeObjectsJob(Job): class ArrangeObjectsJob(Job):
def __init__(self, nodes: List[SceneNode], fixed_nodes: List[SceneNode], min_offset = 8) -> None: def __init__(self, nodes: List[SceneNode], fixed_nodes: List[SceneNode], min_offset = 8) -> None:
@ -29,79 +23,23 @@ class ArrangeObjectsJob(Job):
def run(self): def run(self):
status_message = Message(i18n_catalog.i18nc("@info:status", "Finding new location for objects"), status_message = Message(i18n_catalog.i18nc("@info:status", "Finding new location for objects"),
lifetime = 0, lifetime = 0,
dismissable=False, dismissable = False,
progress = 0, progress = 0,
title = i18n_catalog.i18nc("@info:title", "Finding Location")) title = i18n_catalog.i18nc("@info:title", "Finding Location"))
status_message.show() status_message.show()
global_container_stack = Application.getInstance().getGlobalContainerStack()
machine_width = global_container_stack.getProperty("machine_width", "value")
machine_depth = global_container_stack.getProperty("machine_depth", "value")
arranger = Arrange.create(x = machine_width, y = machine_depth, fixed_nodes = self._fixed_nodes, min_offset = self._min_offset) found_solution_for_all = None
try:
# Build set to exclude children (those get arranged together with the parents). found_solution_for_all = arrange(self._nodes, Application.getInstance().getBuildVolume(), self._fixed_nodes)
included_as_child = set() except: # If the thread crashes, the message should still close
for node in self._nodes: Logger.logException("e", "Unable to arrange the objects on the buildplate. The arrange algorithm has crashed.")
included_as_child.update(node.getAllChildren())
# Collect nodes to be placed
nodes_arr = [] # fill with (size, node, offset_shape_arr, hull_shape_arr)
for node in self._nodes:
if node in included_as_child:
continue
offset_shape_arr, hull_shape_arr = ShapeArray.fromNode(node, min_offset = self._min_offset, include_children = True)
if offset_shape_arr is None:
Logger.log("w", "Node [%s] could not be converted to an array for arranging...", str(node))
continue
nodes_arr.append((offset_shape_arr.arr.shape[0] * offset_shape_arr.arr.shape[1], node, offset_shape_arr, hull_shape_arr))
# Sort the nodes with the biggest area first.
nodes_arr.sort(key=lambda item: item[0])
nodes_arr.reverse()
# Place nodes one at a time
start_priority = 0
last_priority = start_priority
last_size = None
grouped_operation = GroupedOperation()
found_solution_for_all = True
not_fit_count = 0
for idx, (size, node, offset_shape_arr, hull_shape_arr) in enumerate(nodes_arr):
# For performance reasons, we assume that when a location does not fit,
# it will also not fit for the next object (while what can be untrue).
if last_size == size: # This optimization works if many of the objects have the same size
start_priority = last_priority
else:
start_priority = 0
best_spot = arranger.bestSpot(hull_shape_arr, start_prio = start_priority)
x, y = best_spot.x, best_spot.y
node.removeDecorator(ZOffsetDecorator)
if node.getBoundingBox():
center_y = node.getWorldPosition().y - node.getBoundingBox().bottom
else:
center_y = 0
if x is not None: # We could find a place
last_size = size
last_priority = best_spot.priority
arranger.place(x, y, offset_shape_arr) # take place before the next one
grouped_operation.addOperation(TranslateOperation(node, Vector(x, center_y, y), set_position = True))
else:
Logger.log("d", "Arrange all: could not find spot!")
found_solution_for_all = False
grouped_operation.addOperation(TranslateOperation(node, Vector(200, center_y, -not_fit_count * 20), set_position = True))
not_fit_count += 1
status_message.setProgress((idx + 1) / len(nodes_arr) * 100)
Job.yieldThread()
grouped_operation.push()
status_message.hide() status_message.hide()
if found_solution_for_all is not None and not found_solution_for_all:
if not found_solution_for_all: no_full_solution_message = Message(
no_full_solution_message = Message(i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"), i18n_catalog.i18nc("@info:status",
title = i18n_catalog.i18nc("@info:title", "Can't Find Location")) "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.ERROR)
no_full_solution_message.show() no_full_solution_message.show()
self.finished.emit(self) self.finished.emit(self)

View file

@ -0,0 +1,148 @@
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import numpy
from pynest2d import Point, Box, Item, NfpConfig, nest
from typing import List, TYPE_CHECKING, Optional, Tuple
from UM.Application import Application
from UM.Logger import Logger
from UM.Math.Matrix import Matrix
from UM.Math.Polygon import Polygon
from UM.Math.Quaternion import Quaternion
from UM.Math.Vector import Vector
from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
from UM.Operations.GroupedOperation import GroupedOperation
from UM.Operations.RotateOperation import RotateOperation
from UM.Operations.TranslateOperation import TranslateOperation
if TYPE_CHECKING:
from UM.Scene.SceneNode import SceneNode
from cura.BuildVolume import BuildVolume
def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildVolume", fixed_nodes: Optional[List["SceneNode"]] = None, factor = 10000) -> Tuple[bool, List[Item]]:
"""
Find placement for a set of scene nodes, but don't actually move them just yet.
:param nodes_to_arrange: The list of nodes that need to be moved.
:param build_volume: The build volume that we want to place the nodes in. It gets size & disallowed areas from this.
:param fixed_nodes: List of nods that should not be moved, but should be used when deciding where the others nodes
are placed.
:param factor: The library that we use is int based. This factor defines how accurate we want it to be.
:return: tuple (found_solution_for_all, node_items)
WHERE
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()
build_plate_bounding_box = Box(machine_width * factor, machine_depth * factor)
if fixed_nodes is None:
fixed_nodes = []
# Add all the items we want to arrange
node_items = []
for node in nodes_to_arrange:
hull_polygon = node.callDecoration("getConvexHull")
if not hull_polygon or hull_polygon.getPoints is None:
Logger.log("w", "Object {} cannot be arranged because it has no convex hull.".format(node.getName()))
continue
converted_points = []
for point in hull_polygon.getPoints():
converted_points.append(Point(int(point[0] * factor), int(point[1] * factor)))
item = Item(converted_points)
node_items.append(item)
# Use a tiny margin for the build_plate_polygon (the nesting doesn't like overlapping disallowed areas)
half_machine_width = 0.5 * machine_width - 1
half_machine_depth = 0.5 * machine_depth - 1
build_plate_polygon = Polygon(numpy.array([
[half_machine_width, -half_machine_depth],
[-half_machine_width, -half_machine_depth],
[-half_machine_width, half_machine_depth],
[half_machine_width, half_machine_depth]
], numpy.float32))
disallowed_areas = build_volume.getDisallowedAreas()
num_disallowed_areas_added = 0
for area in disallowed_areas:
converted_points = []
# 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 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)))
disallowed_area = Item(converted_points)
disallowed_area.markAsDisallowedAreaInBin(0)
node_items.append(disallowed_area)
num_disallowed_areas_added += 1
for node in fixed_nodes:
converted_points = []
hull_polygon = node.callDecoration("getConvexHull")
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)
item.markAsFixedInBin(0)
node_items.append(item)
num_disallowed_areas_added += 1
config = NfpConfig()
config.accuracy = 1.0
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))
found_solution_for_all = num_bins == 1
return found_solution_for_all, node_items
def arrange(nodes_to_arrange: List["SceneNode"], build_volume: "BuildVolume", fixed_nodes: Optional[List["SceneNode"]] = None, factor = 10000, add_new_nodes_in_scene: bool = False) -> bool:
"""
Find placement for a set of scene nodes, and move them by using a single grouped operation.
:param nodes_to_arrange: The list of nodes that need to be moved.
:param build_volume: The build volume that we want to place the nodes in. It gets size & disallowed areas from this.
:param fixed_nodes: List of nods that should not be moved, but should be used when deciding where the others nodes
are placed.
:param factor: The library that we use is int based. This factor defines how accuracte we want it to be.
:param add_new_nodes_in_scene: Whether to create new scene nodes before applying the transformations and rotations
:return: found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
"""
scene_root = Application.getInstance().getController().getScene().getRoot()
found_solution_for_all, node_items = findNodePlacement(nodes_to_arrange, build_volume, fixed_nodes, factor)
not_fit_count = 0
grouped_operation = GroupedOperation()
for node, node_item in zip(nodes_to_arrange, node_items):
if add_new_nodes_in_scene:
grouped_operation.addOperation(AddSceneNodeOperation(node, scene_root))
if node_item.binId() == 0:
# We found a spot for it
rotation_matrix = Matrix()
rotation_matrix.setByRotationAxis(node_item.rotation(), Vector(0, -1, 0))
grouped_operation.addOperation(RotateOperation(node, Quaternion.fromMatrix(rotation_matrix)))
grouped_operation.addOperation(TranslateOperation(node, Vector(node_item.translation().x() / factor, 0,
node_item.translation().y() / factor)))
else:
# We didn't find a spot
grouped_operation.addOperation(
TranslateOperation(node, Vector(200, node.getWorldPosition().y, -not_fit_count * 20), set_position = True))
not_fit_count += 1
grouped_operation.push()
return found_solution_for_all

View file

@ -3,7 +3,7 @@
import numpy import numpy
import copy import copy
from typing import Optional, Tuple, TYPE_CHECKING from typing import Optional, Tuple, TYPE_CHECKING, Union
from UM.Math.Polygon import Polygon from UM.Math.Polygon import Polygon
@ -11,19 +11,24 @@ if TYPE_CHECKING:
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
## Polygon representation as an array for use with Arrange
class ShapeArray: class ShapeArray:
def __init__(self, arr: numpy.array, offset_x: float, offset_y: float, scale: float = 1) -> None: """Polygon representation as an array for use with :py:class:`cura.Arranging.Arrange.Arrange`"""
def __init__(self, arr: numpy.ndarray, offset_x: float, offset_y: float, scale: float = 1) -> None:
self.arr = arr self.arr = arr
self.offset_x = offset_x self.offset_x = offset_x
self.offset_y = offset_y self.offset_y = offset_y
self.scale = scale self.scale = scale
## Instantiate from a bunch of vertices
# \param vertices
# \param scale scale the coordinates
@classmethod @classmethod
def fromPolygon(cls, vertices: numpy.array, scale: float = 1) -> "ShapeArray": def fromPolygon(cls, vertices: numpy.ndarray, scale: float = 1) -> "ShapeArray":
"""Instantiate from a bunch of vertices
:param vertices:
:param scale: scale the coordinates
:return: a shape array instantiated from a bunch of vertices
"""
# scale # scale
vertices = vertices * scale vertices = vertices * scale
# flip y, x -> x, y # flip y, x -> x, y
@ -44,12 +49,16 @@ class ShapeArray:
arr[0][0] = 1 arr[0][0] = 1
return cls(arr, offset_x, offset_y) return cls(arr, offset_x, offset_y)
## Instantiate an offset and hull ShapeArray from a scene node.
# \param node source node where the convex hull must be present
# \param min_offset offset for the offset ShapeArray
# \param scale scale the coordinates
@classmethod @classmethod
def fromNode(cls, node: "SceneNode", min_offset: float, scale: float = 0.5, include_children: bool = False) -> Tuple[Optional["ShapeArray"], Optional["ShapeArray"]]: def fromNode(cls, node: "SceneNode", min_offset: float, scale: float = 0.5, include_children: bool = False) -> Tuple[Optional["ShapeArray"], Optional["ShapeArray"]]:
"""Instantiate an offset and hull ShapeArray from a scene node.
:param node: source node where the convex hull must be present
:param min_offset: offset for the offset ShapeArray
:param scale: scale the coordinates
:return: A tuple containing an offset and hull shape array
"""
transform = node._transformation transform = node._transformation
transform_x = transform._data[0][3] transform_x = transform._data[0][3]
transform_y = transform._data[2][3] transform_y = transform._data[2][3]
@ -88,15 +97,20 @@ class ShapeArray:
return offset_shape_arr, hull_shape_arr return offset_shape_arr, hull_shape_arr
## Create np.array with dimensions defined by shape
# Fills polygon defined by vertices with ones, all other values zero
# Only works correctly for convex hull vertices
# Originally from: http://stackoverflow.com/questions/37117878/generating-a-filled-polygon-inside-a-numpy-array
# \param shape numpy format shape, [x-size, y-size]
# \param vertices
@classmethod @classmethod
def arrayFromPolygon(cls, shape: Tuple[int, int], vertices: numpy.array) -> numpy.array: def arrayFromPolygon(cls, shape: Union[Tuple[int, int], numpy.ndarray], vertices: numpy.ndarray) -> numpy.ndarray:
base_array = numpy.zeros(shape, dtype = numpy.int32) # Initialize your array of zeros """Create :py:class:`numpy.ndarray` with dimensions defined by shape
Fills polygon defined by vertices with ones, all other values zero
Only works correctly for convex hull vertices
Originally from: `Stackoverflow - generating a filled polygon inside a numpy array <https://stackoverflow.com/questions/37117878/generating-a-filled-polygon-inside-a-numpy-array>`_
:param shape: numpy format shape, [x-size, y-size]
:param vertices:
:return: numpy array with dimensions defined by shape
"""
base_array = numpy.zeros(shape, dtype = numpy.int32) # type: ignore # Initialize your array of zeros
fill = numpy.ones(base_array.shape) * True # Initialize boolean array defining shape fill fill = numpy.ones(base_array.shape) * True # Initialize boolean array defining shape fill
@ -111,16 +125,21 @@ class ShapeArray:
return base_array return base_array
## Return indices that mark one side of the line, used by arrayFromPolygon
# Uses the line defined by p1 and p2 to check array of
# input indices against interpolated value
# Returns boolean array, with True inside and False outside of shape
# Originally from: http://stackoverflow.com/questions/37117878/generating-a-filled-polygon-inside-a-numpy-array
# \param p1 2-tuple with x, y for point 1
# \param p2 2-tuple with x, y for point 2
# \param base_array boolean array to project the line on
@classmethod @classmethod
def _check(cls, p1: numpy.array, p2: numpy.array, base_array: numpy.array) -> Optional[numpy.array]: def _check(cls, p1: numpy.ndarray, p2: numpy.ndarray, base_array: numpy.ndarray) -> Optional[numpy.ndarray]:
"""Return indices that mark one side of the line, used by arrayFromPolygon
Uses the line defined by p1 and p2 to check array of
input indices against interpolated value
Returns boolean array, with True inside and False outside of shape
Originally from: `Stackoverflow - generating a filled polygon inside a numpy array <https://stackoverflow.com/questions/37117878/generating-a-filled-polygon-inside-a-numpy-array>`_
:param p1: 2-tuple with x, y for point 1
:param p2: 2-tuple with x, y for point 2
:param base_array: boolean array to project the line on
:return: A numpy array with indices that mark one side of the line
"""
if p1[0] == p2[0] and p1[1] == p2[1]: if p1[0] == p2[0] and p1[1] == p2[1]:
return None return None
idxs = numpy.indices(base_array.shape) # Create 3D array of indices idxs = numpy.indices(base_array.shape) # Create 3D array of indices

View file

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

View file

@ -5,42 +5,54 @@ import io
import os import os
import re import re
import shutil import shutil
from copy import deepcopy
from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile
from typing import Dict, Optional, TYPE_CHECKING from typing import Dict, Optional, TYPE_CHECKING, List
from UM import i18nCatalog from UM import i18nCatalog
from UM.Logger import Logger from UM.Logger import Logger
from UM.Message import Message from UM.Message import Message
from UM.Platform import Platform from UM.Platform import Platform
from UM.Resources import Resources from UM.Resources import Resources
from UM.Version import Version
if TYPE_CHECKING: if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
## The back-up class holds all data about a back-up.
#
# It is also responsible for reading and writing the zip file to the user data
# folder.
class Backup: class Backup:
# These files should be ignored when making a backup. """The back-up class holds all data about a back-up.
IGNORED_FILES = [r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc"]
It is also responsible for reading and writing the zip file to the user data folder.
"""
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"""
# Re-use translation catalog.
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
"""Re-use translation catalog"""
def __init__(self, application: "CuraApplication", zip_file: bytes = None, meta_data: Dict[str, str] = None) -> None: def __init__(self, application: "CuraApplication", zip_file: bytes = None, meta_data: Dict[str, str] = None) -> None:
self._application = application self._application = application
self.zip_file = zip_file # type: Optional[bytes] self.zip_file = zip_file # type: Optional[bytes]
self.meta_data = meta_data # type: Optional[Dict[str, str]] self.meta_data = meta_data # type: Optional[Dict[str, str]]
## Create a back-up from the current user config folder.
def makeFromCurrent(self) -> None: def makeFromCurrent(self) -> None:
"""Create a back-up from the current user config folder."""
cura_release = self._application.getVersion() cura_release = self._application.getVersion()
version_data_dir = Resources.getDataStoragePath() version_data_dir = Resources.getDataStoragePath()
Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir) Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir)
# obfuscate sensitive secrets
secrets = self._obfuscate()
# Ensure all current settings are saved. # Ensure all current settings are saved.
self._application.saveSettings() self._application.saveSettings()
@ -62,11 +74,12 @@ class Backup:
files = archive.namelist() files = archive.namelist()
# Count the metadata items. We do this in a rather naive way at the moment. # Count the metadata items. We do this in a rather naive way at the moment.
machine_count = len([s for s in files if "machine_instances/" in s]) - 1 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 = len([s for s in files if "materials/" in s]) - 1 material_count = max(len([s for s in files if "materials/" in s]) - 1, 0)
profile_count = len([s for s in files if "quality_changes/" in s]) - 1 profile_count = max(len([s for s in files if "quality_changes/" in s]) - 1, 0)
plugin_count = len([s for s in files if "plugin.json" in s]) # We don't store plugins anymore, since if you can make backups, you have an account (and the plugins are
# on the marketplace anyway)
plugin_count = 0
# Store the archive and metadata so the BackupManager can fetch them when needed. # Store the archive and metadata so the BackupManager can fetch them when needed.
self.zip_file = buffer.getvalue() self.zip_file = buffer.getvalue()
self.meta_data = { self.meta_data = {
@ -76,12 +89,16 @@ class Backup:
"profile_count": str(profile_count), "profile_count": str(profile_count),
"plugin_count": str(plugin_count) "plugin_count": str(plugin_count)
} }
# Restore the obfuscated settings
self._illuminate(**secrets)
## Make a full archive from the given root path with the given name.
# \param root_path The root directory to archive recursively.
# \return The archive as bytes.
def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]: def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]:
ignore_string = re.compile("|".join(self.IGNORED_FILES)) """Make a full archive from the given root path with the given name.
:param root_path: The root directory to archive recursively.
:return: The archive as bytes.
"""
ignore_string = re.compile("|".join(self.IGNORED_FILES + self.IGNORED_FOLDERS))
try: try:
archive = ZipFile(buffer, "w", ZIP_DEFLATED) archive = ZipFile(buffer, "w", ZIP_DEFLATED)
for root, folders, files in os.walk(root_path): for root, folders, files in os.walk(root_path):
@ -94,39 +111,55 @@ class Backup:
return archive return archive
except (IOError, OSError, BadZipfile) as error: except (IOError, OSError, BadZipfile) as error:
Logger.log("e", "Could not create archive from user data directory: %s", error) Logger.log("e", "Could not create archive from user data directory: %s", error)
self._showMessage( self._showMessage(self.catalog.i18nc("@info:backup_failed",
self.catalog.i18nc("@info:backup_failed", "Could not create archive from user data directory: {}".format(error)),
"Could not create archive from user data directory: {}".format(error))) message_type = Message.MessageType.ERROR)
return None return None
## Show a UI message. def _showMessage(self, message: str, message_type: Message.MessageType = Message.MessageType.NEUTRAL) -> None:
def _showMessage(self, message: str) -> 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()
## Restore this back-up.
# \return Whether we had success or not.
def restore(self) -> bool: def restore(self) -> bool:
"""Restore this back-up.
:return: Whether we had success or not.
"""
if not self.zip_file or not self.meta_data or not self.meta_data.get("cura_release", None): 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. # We can restore without the minimum required information.
Logger.log("w", "Tried to restore a Cura backup without having proper data or meta data.") Logger.log("w", "Tried to restore a Cura backup without having proper data or meta data.")
self._showMessage( self._showMessage(self.catalog.i18nc("@info:backup_failed",
self.catalog.i18nc("@info:backup_failed", "Tried to restore a Cura backup without having proper data or meta data."),
"Tried to restore a Cura backup without having proper data or meta data.")) message_type = Message.MessageType.ERROR)
return False return False
current_version = self._application.getVersion() current_version = Version(self._application.getVersion())
version_to_restore = self.meta_data.get("cura_release", "master") version_to_restore = Version(self.meta_data.get("cura_release", "master"))
if current_version < version_to_restore: if current_version < version_to_restore:
# Cannot restore version newer than current because settings might have changed. # Cannot restore version newer than current because settings might have changed.
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)) 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._showMessage(self.catalog.i18nc("@info:backup_failed",
self.catalog.i18nc("@info:backup_failed", "Tried to restore a Cura backup that is higher than the current version."),
"Tried to restore a Cura backup that is higher than the current version.")) message_type = Message.MessageType.ERROR)
return False return False
# Get the current secrets and store since the back-up doesn't contain those
secrets = self._obfuscate()
version_data_dir = Resources.getDataStoragePath() version_data_dir = Resources.getDataStoragePath()
archive = ZipFile(io.BytesIO(self.zip_file), "r") try:
archive = ZipFile(io.BytesIO(self.zip_file), "r")
except LookupError as e:
Logger.log("d", f"The following error occurred while trying to restore a Cura backup: {str(e)}")
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) extracted = self._extractArchive(archive, version_data_dir)
# Under Linux, preferences are stored elsewhere, so we copy the file to there. # Under Linux, preferences are stored elsewhere, so we copy the file to there.
@ -137,14 +170,22 @@ class Backup:
Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file) Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file)
shutil.move(backup_preferences_file, preferences_file) shutil.move(backup_preferences_file, preferences_file)
# Read the preferences from the newly restored configuration (or else the cached Preferences will override the restored ones)
self._application.readPreferencesFromConfiguration()
# Restore the obfuscated settings
self._illuminate(**secrets)
return extracted return extracted
## Extract the whole archive to the given target path.
# \param archive The archive as ZipFile.
# \param target_path The target path.
# \return Whether we had success or not.
@staticmethod @staticmethod
def _extractArchive(archive: "ZipFile", target_path: str) -> bool: def _extractArchive(archive: "ZipFile", target_path: str) -> bool:
"""Extract the whole archive to the given target path.
:param archive: The archive as ZipFile.
:param target_path: The target path.
:return: Whether we had success or not.
"""
# Implement security recommendations: Sanity check on zip files will make it harder to spoof. # Implement security recommendations: Sanity check on zip files will make it harder to spoof.
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
@ -156,9 +197,36 @@ class Backup:
Logger.log("d", "Removing current data in location: %s", target_path) Logger.log("d", "Removing current data in location: %s", target_path)
Resources.factoryReset() Resources.factoryReset()
Logger.log("d", "Extracting backup to location: %s", target_path) Logger.log("d", "Extracting backup to location: %s", target_path)
try: name_list = archive.namelist()
archive.extractall(target_path) for archive_filename in name_list:
except PermissionError: try:
Logger.logException("e", "Unable to extract the backup due to permission errors") archive.extract(archive_filename, target_path)
return False except (PermissionError, EnvironmentError):
Logger.logException("e", f"Unable to extract the file {archive_filename} from the backup due to permission or file system errors.")
CuraApplication.getInstance().processEvents()
return True return True
def _obfuscate(self) -> Dict[str, str]:
"""
Obfuscate and remove the secret preferences that are specified in SECRETS_SETTINGS
:return: a dictionary of the removed secrets. Note: the '/' is replaced by '__'
"""
preferences = self._application.getPreferences()
secrets = {}
for secret in self.SECRETS_SETTINGS:
secrets[secret.replace("/", "__")] = deepcopy(preferences.getValue(secret))
preferences.setValue(secret, None)
self._application.savePreferences()
return secrets
def _illuminate(self, **kwargs) -> None:
"""
Restore the obfuscated settings
:param kwargs: a dict of obscured preferences. Note: the '__' of the keys will be replaced by '/'
"""
preferences = self._application.getPreferences()
for key, value in kwargs.items():
preferences.setValue(key.replace("__", "/"), value)
self._application.savePreferences()

View file

@ -4,24 +4,31 @@
from typing import Dict, Optional, Tuple, TYPE_CHECKING from typing import Dict, Optional, Tuple, TYPE_CHECKING
from UM.Logger import Logger from UM.Logger import Logger
from UM.Version import Version
from cura.Backups.Backup import Backup from cura.Backups.Backup import Backup
if TYPE_CHECKING: if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
## The BackupsManager is responsible for managing the creating and restoring of
# back-ups.
#
# Back-ups themselves are represented in a different class.
class BackupsManager: class BackupsManager:
"""
The BackupsManager is responsible for managing the creating and restoring of
back-ups.
Back-ups themselves are represented in a different class.
"""
def __init__(self, application: "CuraApplication") -> None: def __init__(self, application: "CuraApplication") -> None:
self._application = application self._application = application
## Get a back-up of the current configuration.
# \return A tuple containing a ZipFile (the actual back-up) and a dict
# containing some metadata (like version).
def createBackup(self) -> Tuple[Optional[bytes], Optional[Dict[str, str]]]: def createBackup(self) -> Tuple[Optional[bytes], Optional[Dict[str, str]]]:
"""
Get a back-up of the current configuration.
:return: A tuple containing a ZipFile (the actual back-up) and a dict containing some metadata (like version).
"""
self._disableAutoSave() self._disableAutoSave()
backup = Backup(self._application) backup = Backup(self._application)
backup.makeFromCurrent() backup.makeFromCurrent()
@ -29,11 +36,14 @@ class BackupsManager:
# We don't return a Backup here because we want plugins only to interact with our API and not full objects. # We don't return a Backup here because we want plugins only to interact with our API and not full objects.
return backup.zip_file, backup.meta_data return backup.zip_file, backup.meta_data
## Restore a back-up from a given ZipFile.
# \param zip_file A bytes object containing the actual back-up.
# \param meta_data A dict containing some metadata that is needed to
# restore the back-up correctly.
def restoreBackup(self, zip_file: bytes, meta_data: Dict[str, str]) -> None: def restoreBackup(self, zip_file: bytes, meta_data: Dict[str, str]) -> None:
"""
Restore a back-up from a given ZipFile.
:param zip_file: A bytes object containing the actual back-up.
:param meta_data: A dict containing some metadata that is needed to restore the back-up correctly.
"""
if not meta_data.get("cura_release", None): if not meta_data.get("cura_release", None):
# If there is no "cura_release" specified in the meta data, we don't execute a backup restore. # If there is no "cura_release" specified in the meta data, we don't execute a backup restore.
Logger.log("w", "Tried to restore a backup without specifying a Cura version number.") Logger.log("w", "Tried to restore a backup without specifying a Cura version number.")
@ -43,14 +53,16 @@ class BackupsManager:
backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data) backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data)
restored = backup.restore() restored = backup.restore()
if restored: if restored:
# At this point, Cura will need to restart for the changes to take effect. # At this point, Cura will need to restart for the changes to take effect.
# We don't want to store the data at this point as that would override the just-restored backup. # We don't want to store the data at this point as that would override the just-restored backup.
self._application.windowClosed(save_data = False) self._application.windowClosed(save_data = False)
## Here we try to disable the auto-save plug-in as it might interfere with
# restoring a back-up.
def _disableAutoSave(self) -> None: def _disableAutoSave(self) -> None:
"""Here we (try to) disable the saving as it might interfere with restoring a back-up."""
self._application.enableSave(False)
auto_save = self._application.getAutoSave() auto_save = self._application.getAutoSave()
# The auto save is only not created if the application has not yet started. # The auto save is only not created if the application has not yet started.
if auto_save: if auto_save:
@ -58,8 +70,10 @@ class BackupsManager:
else: else:
Logger.log("e", "Unable to disable the autosave as application init has not been completed") Logger.log("e", "Unable to disable the autosave as application init has not been completed")
## Re-enable auto-save after we're done.
def _enableAutoSave(self) -> None: def _enableAutoSave(self) -> None:
"""Re-enable auto-save and other saving after we're done."""
self._application.enableSave(True)
auto_save = self._application.getAutoSave() auto_save = self._application.getAutoSave()
# The auto save is only not created if the application has not yet started. # The auto save is only not created if the application has not yet started.
if auto_save: if auto_save:

View file

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import numpy import numpy
@ -44,8 +44,9 @@ catalog = i18nCatalog("cura")
PRIME_CLEARANCE = 6.5 PRIME_CLEARANCE = 6.5
## Build volume is a special kind of node that is responsible for rendering the printable area & disallowed areas.
class BuildVolume(SceneNode): class BuildVolume(SceneNode):
"""Build volume is a special kind of node that is responsible for rendering the printable area & disallowed areas."""
raftThicknessChanged = Signal() raftThicknessChanged = Signal()
def __init__(self, application: "CuraApplication", parent: Optional[SceneNode] = None) -> None: def __init__(self, application: "CuraApplication", parent: Optional[SceneNode] = None) -> None:
@ -91,10 +92,14 @@ class BuildVolume(SceneNode):
self._adhesion_type = None # type: Any self._adhesion_type = None # type: Any
self._platform = Platform(self) self._platform = Platform(self)
self._edge_disallowed_size = None
self._build_volume_message = Message(catalog.i18nc("@info:status", self._build_volume_message = Message(catalog.i18nc("@info:status",
"The build volume height has been reduced due to the value of the" "The build volume height has been reduced due to the value of the"
" \"Print Sequence\" setting to prevent the gantry from colliding" " \"Print Sequence\" setting to prevent the gantry from colliding"
" with printed models."), title = catalog.i18nc("@info:title", "Build Volume")) " with printed models."),
title = catalog.i18nc("@info:title", "Build Volume"),
message_type = Message.MessageType.WARNING)
self._global_container_stack = None # type: Optional[GlobalStack] self._global_container_stack = None # type: Optional[GlobalStack]
@ -105,19 +110,17 @@ class BuildVolume(SceneNode):
self._application.globalContainerStackChanged.connect(self._onStackChanged) self._application.globalContainerStackChanged.connect(self._onStackChanged)
self._onStackChanged()
self._engine_ready = False self._engine_ready = False
self._application.engineCreatedSignal.connect(self._onEngineCreated) self._application.engineCreatedSignal.connect(self._onEngineCreated)
self._has_errors = False self._has_errors = False
self._application.getController().getScene().sceneChanged.connect(self._onSceneChanged) self._application.getController().getScene().sceneChanged.connect(self._onSceneChanged)
#Objects loaded at the moment. We are connected to the property changed events of these objects. # Objects loaded at the moment. We are connected to the property changed events of these objects.
self._scene_objects = set() # type: Set[SceneNode] self._scene_objects = set() # type: Set[SceneNode]
self._scene_change_timer = QTimer() self._scene_change_timer = QTimer()
self._scene_change_timer.setInterval(100) self._scene_change_timer.setInterval(200)
self._scene_change_timer.setSingleShot(True) self._scene_change_timer.setSingleShot(True)
self._scene_change_timer.timeout.connect(self._onSceneChangeTimerFinished) self._scene_change_timer.timeout.connect(self._onSceneChangeTimerFinished)
@ -163,10 +166,12 @@ class BuildVolume(SceneNode):
self._scene_objects = new_scene_objects self._scene_objects = new_scene_objects
self._onSettingPropertyChanged("print_sequence", "value") # Create fake event, so right settings are triggered. self._onSettingPropertyChanged("print_sequence", "value") # Create fake event, so right settings are triggered.
## Updates the listeners that listen for changes in per-mesh stacks.
#
# \param node The node for which the decorators changed.
def _updateNodeListeners(self, node: SceneNode): def _updateNodeListeners(self, node: SceneNode):
"""Updates the listeners that listen for changes in per-mesh stacks.
:param node: The node for which the decorators changed.
"""
per_mesh_stack = node.callDecoration("getStack") per_mesh_stack = node.callDecoration("getStack")
if per_mesh_stack: if per_mesh_stack:
per_mesh_stack.propertyChanged.connect(self._onSettingPropertyChanged) per_mesh_stack.propertyChanged.connect(self._onSettingPropertyChanged)
@ -177,20 +182,33 @@ class BuildVolume(SceneNode):
def setWidth(self, width: float) -> None: def setWidth(self, width: float) -> None:
self._width = width self._width = width
def getWidth(self) -> float:
return self._width
def setHeight(self, height: float) -> None: def setHeight(self, height: float) -> None:
self._height = height self._height = height
def getHeight(self) -> float:
return self._height
def setDepth(self, depth: float) -> None: def setDepth(self, depth: float) -> None:
self._depth = depth self._depth = depth
def getDepth(self) -> float:
return self._depth
def setShape(self, shape: str) -> None: def setShape(self, shape: str) -> None:
if shape: if shape:
self._shape = shape self._shape = shape
## Get the length of the 3D diagonal through the build volume.
#
# This gives a sense of the scale of the build volume in general.
def getDiagonalSize(self) -> float: def getDiagonalSize(self) -> float:
"""Get the length of the 3D diagonal through the build volume.
This gives a sense of the scale of the build volume in general.
:return: length of the 3D diagonal through the build volume
"""
return math.sqrt(self._width * self._width + self._height * self._height + self._depth * self._depth) return math.sqrt(self._width * self._width + self._height * self._height + self._depth * self._depth)
def getDisallowedAreas(self) -> List[Polygon]: def getDisallowedAreas(self) -> List[Polygon]:
@ -226,9 +244,9 @@ class BuildVolume(SceneNode):
return True return True
## For every sliceable node, update node._outside_buildarea
#
def updateNodeBoundaryCheck(self): def updateNodeBoundaryCheck(self):
"""For every sliceable node, update node._outside_buildarea"""
if not self._global_container_stack: if not self._global_container_stack:
return return
@ -265,7 +283,7 @@ class BuildVolume(SceneNode):
continue continue
# If the entire node is below the build plate, still mark it as outside. # If the entire node is below the build plate, still mark it as outside.
node_bounding_box = node.getBoundingBox() node_bounding_box = node.getBoundingBox()
if node_bounding_box and node_bounding_box.top < 0: if node_bounding_box and node_bounding_box.top < 0 and not node.getParent().callDecoration("isGroup"):
node.setOutsideBuildArea(True) node.setOutsideBuildArea(True)
continue continue
# Mark the node as outside build volume if the set extruder is disabled # Mark the node as outside build volume if the set extruder is disabled
@ -274,7 +292,9 @@ class BuildVolume(SceneNode):
if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled: if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled:
node.setOutsideBuildArea(True) node.setOutsideBuildArea(True)
continue continue
except IndexError: except IndexError: # Happens when the extruder list is too short. We're not done building the printer in memory yet.
continue
except TypeError: # Happens when extruder_position is None. This object has no extruder decoration.
continue continue
node.setOutsideBuildArea(False) node.setOutsideBuildArea(False)
@ -293,8 +313,13 @@ class BuildVolume(SceneNode):
for child_node in children: for child_node in children:
child_node.setOutsideBuildArea(group_node.isOutsideBuildArea()) child_node.setOutsideBuildArea(group_node.isOutsideBuildArea())
## Update the outsideBuildArea of a single node, given bounds or current build volume
def checkBoundsAndUpdate(self, node: CuraSceneNode, bounds: Optional[AxisAlignedBox] = None) -> None: def checkBoundsAndUpdate(self, node: CuraSceneNode, bounds: Optional[AxisAlignedBox] = None) -> None:
"""Update the outsideBuildArea of a single node, given bounds or current build volume
:param node: single node
:param bounds: bounds or current build volume
"""
if not isinstance(node, CuraSceneNode) or self._global_container_stack is None: if not isinstance(node, CuraSceneNode) or self._global_container_stack is None:
return return
@ -321,7 +346,12 @@ class BuildVolume(SceneNode):
# Mark the node as outside build volume if the set extruder is disabled # Mark the node as outside build volume if the set extruder is disabled
extruder_position = node.callDecoration("getActiveExtruderPosition") extruder_position = node.callDecoration("getActiveExtruderPosition")
if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled: try:
if not self._global_container_stack.extruderList[int(extruder_position)].isEnabled:
node.setOutsideBuildArea(True)
return
except IndexError:
# If the extruder doesn't exist, also mark it as unprintable.
node.setOutsideBuildArea(True) node.setOutsideBuildArea(True)
return return
@ -482,8 +512,9 @@ class BuildVolume(SceneNode):
self._disallowed_area_size = max(size, self._disallowed_area_size) self._disallowed_area_size = max(size, self._disallowed_area_size)
return mb.build() return mb.build()
## Recalculates the build volume & disallowed areas.
def rebuild(self) -> None: def rebuild(self) -> None:
"""Recalculates the build volume & disallowed areas."""
if not self._width or not self._height or not self._depth: if not self._width or not self._height or not self._depth:
return return
@ -584,8 +615,9 @@ class BuildVolume(SceneNode):
def _onStackChanged(self): def _onStackChanged(self):
self._stack_change_timer.start() self._stack_change_timer.start()
## Update the build volume visualization
def _onStackChangeTimerFinished(self) -> None: def _onStackChangeTimerFinished(self) -> None:
"""Update the build volume visualization"""
if self._global_container_stack: if self._global_container_stack:
self._global_container_stack.propertyChanged.disconnect(self._onSettingPropertyChanged) self._global_container_stack.propertyChanged.disconnect(self._onSettingPropertyChanged)
extruders = ExtruderManager.getInstance().getActiveExtruderStacks() extruders = ExtruderManager.getInstance().getActiveExtruderStacks()
@ -710,15 +742,15 @@ class BuildVolume(SceneNode):
self._depth = self._global_container_stack.getProperty("machine_depth", "value") self._depth = self._global_container_stack.getProperty("machine_depth", "value")
self._shape = self._global_container_stack.getProperty("machine_shape", "value") self._shape = self._global_container_stack.getProperty("machine_shape", "value")
## Calls _updateDisallowedAreas and makes sure the changes appear in the
# scene.
#
# This is required for a signal to trigger the update in one go. The
# ``_updateDisallowedAreas`` method itself shouldn't call ``rebuild``,
# since there may be other changes before it needs to be rebuilt, which
# would hit performance.
def _updateDisallowedAreasAndRebuild(self): def _updateDisallowedAreasAndRebuild(self):
"""Calls :py:meth:`cura.BuildVolume._updateDisallowedAreas` and makes sure the changes appear in the scene.
This is required for a signal to trigger the update in one go. The
:py:meth:`cura.BuildVolume._updateDisallowedAreas` method itself shouldn't call
:py:meth:`cura.BuildVolume.rebuild`, since there may be other changes before it needs to be rebuilt,
which would hit performance.
"""
self._updateDisallowedAreas() self._updateDisallowedAreas()
self._updateRaftThickness() self._updateRaftThickness()
self._extra_z_clearance = self._calculateExtraZClearance(ExtruderManager.getInstance().getUsedExtruderStacks()) self._extra_z_clearance = self._calculateExtraZClearance(ExtruderManager.getInstance().getUsedExtruderStacks())
@ -731,6 +763,7 @@ class BuildVolume(SceneNode):
self._error_areas = [] self._error_areas = []
used_extruders = ExtruderManager.getInstance().getUsedExtruderStacks() used_extruders = ExtruderManager.getInstance().getUsedExtruderStacks()
self._edge_disallowed_size = None # Force a recalculation
disallowed_border_size = self.getEdgeDisallowedSize() disallowed_border_size = self.getEdgeDisallowedSize()
result_areas = self._computeDisallowedAreasStatic(disallowed_border_size, used_extruders) # Normal machine disallowed areas can always be added. result_areas = self._computeDisallowedAreasStatic(disallowed_border_size, used_extruders) # Normal machine disallowed areas can always be added.
@ -764,7 +797,10 @@ class BuildVolume(SceneNode):
if prime_tower_collision: # Already found a collision. if prime_tower_collision: # Already found a collision.
break break
if self._global_container_stack.getProperty("prime_tower_brim_enable", "value") and self._global_container_stack.getProperty("adhesion_type", "value") != "raft": if self._global_container_stack.getProperty("prime_tower_brim_enable", "value") and self._global_container_stack.getProperty("adhesion_type", "value") != "raft":
prime_tower_areas[extruder_id][area_index] = prime_tower_area.getMinkowskiHull(Polygon.approximatedCircle(disallowed_border_size)) brim_size = self._calculateBedAdhesionSize(used_extruders, "brim")
# Use 2x the brim size, since we need 1x brim size distance due to the object brim and another
# times the brim due to the brim of the prime tower
prime_tower_areas[extruder_id][area_index] = prime_tower_area.getMinkowskiHull(Polygon.approximatedCircle(2 * brim_size, num_segments = 24))
if not prime_tower_collision: if not prime_tower_collision:
result_areas[extruder_id].extend(prime_tower_areas[extruder_id]) result_areas[extruder_id].extend(prime_tower_areas[extruder_id])
result_areas_no_brim[extruder_id].extend(prime_tower_areas[extruder_id]) result_areas_no_brim[extruder_id].extend(prime_tower_areas[extruder_id])
@ -780,15 +816,14 @@ class BuildVolume(SceneNode):
for extruder_id in result_areas_no_brim: for extruder_id in result_areas_no_brim:
self._disallowed_areas_no_brim.extend(result_areas_no_brim[extruder_id]) self._disallowed_areas_no_brim.extend(result_areas_no_brim[extruder_id])
## Computes the disallowed areas for objects that are printed with print
# features.
#
# This means that the brim, travel avoidance and such will be applied to
# these features.
#
# \return A dictionary with for each used extruder ID the disallowed areas
# where that extruder may not print.
def _computeDisallowedAreasPrinted(self, used_extruders): def _computeDisallowedAreasPrinted(self, used_extruders):
"""Computes the disallowed areas for objects that are printed with print features.
This means that the brim, travel avoidance and such will be applied to these features.
:return: A dictionary with for each used extruder ID the disallowed areas where that extruder may not print.
"""
result = {} result = {}
adhesion_extruder = None #type: ExtruderStack adhesion_extruder = None #type: ExtruderStack
for extruder in used_extruders: for extruder in used_extruders:
@ -817,7 +852,7 @@ class BuildVolume(SceneNode):
prime_tower_y += brim_size prime_tower_y += brim_size
radius = prime_tower_size / 2 radius = prime_tower_size / 2
prime_tower_area = Polygon.approximatedCircle(radius) prime_tower_area = Polygon.approximatedCircle(radius, num_segments = 24)
prime_tower_area = prime_tower_area.translate(prime_tower_x - radius, prime_tower_y - radius) prime_tower_area = prime_tower_area.translate(prime_tower_x - radius, prime_tower_y - radius)
prime_tower_area = prime_tower_area.getMinkowskiHull(Polygon.approximatedCircle(0)) prime_tower_area = prime_tower_area.getMinkowskiHull(Polygon.approximatedCircle(0))
@ -826,18 +861,18 @@ class BuildVolume(SceneNode):
return result return result
## Computes the disallowed areas for the prime blobs.
#
# These are special because they are not subject to things like brim or
# travel avoidance. They do get a dilute with the border size though
# because they may not intersect with brims and such of other objects.
#
# \param border_size The size with which to offset the disallowed areas
# due to skirt, brim, travel avoid distance, etc.
# \param used_extruders The extruder stacks to generate disallowed areas
# for.
# \return A dictionary with for each used extruder ID the prime areas.
def _computeDisallowedAreasPrimeBlob(self, border_size: float, used_extruders: List["ExtruderStack"]) -> Dict[str, List[Polygon]]: def _computeDisallowedAreasPrimeBlob(self, border_size: float, used_extruders: List["ExtruderStack"]) -> Dict[str, List[Polygon]]:
"""Computes the disallowed areas for the prime blobs.
These are special because they are not subject to things like brim or travel avoidance. They do get a dilute
with the border size though because they may not intersect with brims and such of other objects.
:param border_size: The size with which to offset the disallowed areas due to skirt, brim, travel avoid distance
, etc.
:param used_extruders: The extruder stacks to generate disallowed areas for.
:return: A dictionary with for each used extruder ID the prime areas.
"""
result = {} # type: Dict[str, List[Polygon]] result = {} # type: Dict[str, List[Polygon]]
if not self._global_container_stack: if not self._global_container_stack:
return result return result
@ -865,25 +900,26 @@ class BuildVolume(SceneNode):
return result return result
## Computes the disallowed areas that are statically placed in the machine.
#
# It computes different disallowed areas depending on the offset of the
# extruder. The resulting dictionary will therefore have an entry for each
# extruder that is used.
#
# \param border_size The size with which to offset the disallowed areas
# due to skirt, brim, travel avoid distance, etc.
# \param used_extruders The extruder stacks to generate disallowed areas
# for.
# \return A dictionary with for each used extruder ID the disallowed areas
# where that extruder may not print.
def _computeDisallowedAreasStatic(self, border_size:float, used_extruders: List["ExtruderStack"]) -> Dict[str, List[Polygon]]: def _computeDisallowedAreasStatic(self, border_size:float, used_extruders: List["ExtruderStack"]) -> Dict[str, List[Polygon]]:
"""Computes the disallowed areas that are statically placed in the machine.
It computes different disallowed areas depending on the offset of the extruder. The resulting dictionary will
therefore have an entry for each extruder that is used.
:param border_size: The size with which to offset the disallowed areas due to skirt, brim, travel avoid distance
, etc.
:param used_extruders: The extruder stacks to generate disallowed areas for.
:return: A dictionary with for each used extruder ID the disallowed areas where that extruder may not print.
"""
# Convert disallowed areas to polygons and dilate them. # Convert disallowed areas to polygons and dilate them.
machine_disallowed_polygons = [] machine_disallowed_polygons = []
if self._global_container_stack is None: if self._global_container_stack is None:
return {} return {}
for area in self._global_container_stack.getProperty("machine_disallowed_areas", "value"): for area in self._global_container_stack.getProperty("machine_disallowed_areas", "value"):
if len(area) == 0:
continue # Numpy doesn't deal well with 0-length arrays, since it can't determine the dimensionality of them.
polygon = Polygon(numpy.array(area, numpy.float32)) polygon = Polygon(numpy.array(area, numpy.float32))
polygon = polygon.getMinkowskiHull(Polygon.approximatedCircle(border_size)) polygon = polygon.getMinkowskiHull(Polygon.approximatedCircle(border_size))
machine_disallowed_polygons.append(polygon) machine_disallowed_polygons.append(polygon)
@ -1008,13 +1044,14 @@ class BuildVolume(SceneNode):
return result return result
## Private convenience function to get a setting from every extruder.
#
# For single extrusion machines, this gets the setting from the global
# stack.
#
# \return A sequence of setting values, one for each extruder.
def _getSettingFromAllExtruders(self, setting_key: str) -> List[Any]: def _getSettingFromAllExtruders(self, setting_key: str) -> List[Any]:
"""Private convenience function to get a setting from every extruder.
For single extrusion machines, this gets the setting from the global stack.
:return: A sequence of setting values, one for each extruder.
"""
all_values = ExtruderManager.getInstance().getAllExtruderSettings(setting_key, "value") all_values = ExtruderManager.getInstance().getAllExtruderSettings(setting_key, "value")
all_types = ExtruderManager.getInstance().getAllExtruderSettings(setting_key, "type") all_types = ExtruderManager.getInstance().getAllExtruderSettings(setting_key, "type")
for i, (setting_value, setting_type) in enumerate(zip(all_values, all_types)): for i, (setting_value, setting_type) in enumerate(zip(all_values, all_types)):
@ -1022,16 +1059,30 @@ class BuildVolume(SceneNode):
all_values[i] = 0 all_values[i] = 0
return all_values return all_values
def _calculateBedAdhesionSize(self, used_extruders): def _calculateBedAdhesionSize(self, used_extruders, adhesion_override = None):
"""Get the bed adhesion size for the global container stack and used extruders
:param adhesion_override: override adhesion type.
Use None to use the global stack default, "none" for no adhesion, "brim" for brim etc.
"""
if self._global_container_stack is None: if self._global_container_stack is None:
return None return None
container_stack = self._global_container_stack container_stack = self._global_container_stack
adhesion_type = container_stack.getProperty("adhesion_type", "value") adhesion_type = adhesion_override
skirt_brim_line_width = self._global_container_stack.getProperty("skirt_brim_line_width", "value") if adhesion_type is None:
adhesion_type = container_stack.getProperty("adhesion_type", "value")
# Skirt_brim_line_width is a bit of an odd one out. The primary bit of the skirt/brim is printed
# with the adhesion extruder, but it also prints one extra line by all other extruders. As such, the
# setting does *not* have a limit_to_extruder setting (which means that we can't ask the global extruder what
# the value is.
adhesion_extruder = self._global_container_stack.getProperty("adhesion_extruder_nr", "value")
skirt_brim_line_width = self._global_container_stack.extruderList[int(adhesion_extruder)].getProperty("skirt_brim_line_width", "value")
initial_layer_line_width_factor = self._global_container_stack.getProperty("initial_layer_line_width_factor", "value") initial_layer_line_width_factor = self._global_container_stack.getProperty("initial_layer_line_width_factor", "value")
# Use brim width if brim is enabled OR the prime tower has a brim. # Use brim width if brim is enabled OR the prime tower has a brim.
if adhesion_type == "brim" or (self._global_container_stack.getProperty("prime_tower_brim_enable", "value") and adhesion_type != "raft"): if adhesion_type == "brim":
brim_line_count = self._global_container_stack.getProperty("brim_line_count", "value") brim_line_count = self._global_container_stack.getProperty("brim_line_count", "value")
bed_adhesion_size = skirt_brim_line_width * brim_line_count * initial_layer_line_width_factor / 100.0 bed_adhesion_size = skirt_brim_line_width * brim_line_count * initial_layer_line_width_factor / 100.0
@ -1040,7 +1091,7 @@ class BuildVolume(SceneNode):
# We don't create an additional line for the extruder we're printing the brim with. # We don't create an additional line for the extruder we're printing the brim with.
bed_adhesion_size -= skirt_brim_line_width * initial_layer_line_width_factor / 100.0 bed_adhesion_size -= skirt_brim_line_width * initial_layer_line_width_factor / 100.0
elif adhesion_type == "skirt": # No brim? Also not on prime tower? Then use whatever the adhesion type is saying: Skirt, raft or none. elif adhesion_type == "skirt":
skirt_distance = self._global_container_stack.getProperty("skirt_gap", "value") skirt_distance = self._global_container_stack.getProperty("skirt_gap", "value")
skirt_line_count = self._global_container_stack.getProperty("skirt_line_count", "value") skirt_line_count = self._global_container_stack.getProperty("skirt_line_count", "value")
@ -1084,25 +1135,34 @@ class BuildVolume(SceneNode):
def _calculateMoveFromWallRadius(self, used_extruders): def _calculateMoveFromWallRadius(self, used_extruders):
move_from_wall_radius = 0 # Moves that start from outer wall. move_from_wall_radius = 0 # Moves that start from outer wall.
all_values = [move_from_wall_radius]
all_values.extend(self._getSettingFromAllExtruders("infill_wipe_dist")) for stack in used_extruders:
move_from_wall_radius = max(all_values) if stack.getProperty("travel_avoid_other_parts", "value"):
avoid_enabled_per_extruder = [stack.getProperty("travel_avoid_other_parts", "value") for stack in used_extruders] move_from_wall_radius = max(move_from_wall_radius, stack.getProperty("travel_avoid_distance", "value"))
travel_avoid_distance_per_extruder = [stack.getProperty("travel_avoid_distance", "value") for stack in used_extruders]
for avoid_other_parts_enabled, avoid_distance in zip(avoid_enabled_per_extruder, travel_avoid_distance_per_extruder): # For each extruder (or just global). infill_wipe_distance = stack.getProperty("infill_wipe_dist", "value")
if avoid_other_parts_enabled: num_walls = stack.getProperty("wall_line_count", "value")
move_from_wall_radius = max(move_from_wall_radius, avoid_distance) if num_walls >= 1: # Infill wipes start from the infill, so subtract the total wall thickness from this.
infill_wipe_distance -= stack.getProperty("wall_line_width_0", "value")
if num_walls >= 2:
infill_wipe_distance -= stack.getProperty("wall_line_width_x", "value") * (num_walls - 1)
move_from_wall_radius = max(move_from_wall_radius, infill_wipe_distance)
return move_from_wall_radius return move_from_wall_radius
## Calculate the disallowed radius around the edge.
#
# This disallowed radius is to allow for space around the models that is
# not part of the collision radius, such as bed adhesion (skirt/brim/raft)
# and travel avoid distance.
def getEdgeDisallowedSize(self): def getEdgeDisallowedSize(self):
"""Calculate the disallowed radius around the edge.
This disallowed radius is to allow for space around the models that is not part of the collision radius,
such as bed adhesion (skirt/brim/raft) and travel avoid distance.
"""
if not self._global_container_stack or not self._global_container_stack.extruderList: if not self._global_container_stack or not self._global_container_stack.extruderList:
return 0 return 0
if self._edge_disallowed_size is not None:
return self._edge_disallowed_size
container_stack = self._global_container_stack container_stack = self._global_container_stack
used_extruders = ExtruderManager.getInstance().getUsedExtruderStacks() used_extruders = ExtruderManager.getInstance().getUsedExtruderStacks()
@ -1118,8 +1178,8 @@ class BuildVolume(SceneNode):
# Now combine our different pieces of data to get the final border size. # Now combine our different pieces of data to get the final border size.
# Support expansion is added to the bed adhesion, since the bed adhesion goes around support. # Support expansion is added to the bed adhesion, since the bed adhesion goes around support.
# Support expansion is added to farthest shield distance, since the shields go around support. # Support expansion is added to farthest shield distance, since the shields go around support.
border_size = max(move_from_wall_radius, support_expansion + farthest_shield_distance, support_expansion + bed_adhesion_size) self._edge_disallowed_size = max(move_from_wall_radius, support_expansion + farthest_shield_distance, support_expansion + bed_adhesion_size)
return border_size return self._edge_disallowed_size
def _clamp(self, value, min_value, max_value): def _clamp(self, value, min_value, max_value):
return max(min(value, max_value), min_value) return max(min(value, max_value), min_value)
@ -1128,10 +1188,10 @@ class BuildVolume(SceneNode):
_skirt_settings = ["adhesion_type", "skirt_gap", "skirt_line_count", "skirt_brim_line_width", "brim_width", "brim_line_count", "raft_margin", "draft_shield_enabled", "draft_shield_dist", "initial_layer_line_width_factor"] _skirt_settings = ["adhesion_type", "skirt_gap", "skirt_line_count", "skirt_brim_line_width", "brim_width", "brim_line_count", "raft_margin", "draft_shield_enabled", "draft_shield_dist", "initial_layer_line_width_factor"]
_raft_settings = ["adhesion_type", "raft_base_thickness", "raft_interface_thickness", "raft_surface_layers", "raft_surface_thickness", "raft_airgap", "layer_0_z_overlap"] _raft_settings = ["adhesion_type", "raft_base_thickness", "raft_interface_thickness", "raft_surface_layers", "raft_surface_thickness", "raft_airgap", "layer_0_z_overlap"]
_extra_z_settings = ["retraction_hop_enabled", "retraction_hop"] _extra_z_settings = ["retraction_hop_enabled", "retraction_hop"]
_prime_settings = ["extruder_prime_pos_x", "extruder_prime_pos_y", "extruder_prime_pos_z", "prime_blob_enable"] _prime_settings = ["extruder_prime_pos_x", "extruder_prime_pos_y", "prime_blob_enable"]
_tower_settings = ["prime_tower_enable", "prime_tower_size", "prime_tower_position_x", "prime_tower_position_y", "prime_tower_brim_enable"] _tower_settings = ["prime_tower_enable", "prime_tower_size", "prime_tower_position_x", "prime_tower_position_y", "prime_tower_brim_enable"]
_ooze_shield_settings = ["ooze_shield_enabled", "ooze_shield_dist"] _ooze_shield_settings = ["ooze_shield_enabled", "ooze_shield_dist"]
_distance_settings = ["infill_wipe_dist", "travel_avoid_distance", "support_offset", "support_enable", "travel_avoid_other_parts", "travel_avoid_supports"] _distance_settings = ["infill_wipe_dist", "travel_avoid_distance", "support_offset", "support_enable", "travel_avoid_other_parts", "travel_avoid_supports", "wall_line_count", "wall_line_width_0", "wall_line_width_x"]
_extruder_settings = ["support_enable", "support_bottom_enable", "support_roof_enable", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "brim_line_count", "adhesion_extruder_nr", "adhesion_type"] #Settings that can affect which extruders are used. _extruder_settings = ["support_enable", "support_bottom_enable", "support_roof_enable", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "brim_line_count", "adhesion_extruder_nr", "adhesion_type"] #Settings that can affect which extruders are used.
_limit_to_extruder_settings = ["wall_extruder_nr", "wall_0_extruder_nr", "wall_x_extruder_nr", "top_bottom_extruder_nr", "infill_extruder_nr", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "adhesion_extruder_nr"] _limit_to_extruder_settings = ["wall_extruder_nr", "wall_0_extruder_nr", "wall_x_extruder_nr", "top_bottom_extruder_nr", "infill_extruder_nr", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "adhesion_extruder_nr"]
_disallowed_area_settings = _skirt_settings + _prime_settings + _tower_settings + _ooze_shield_settings + _distance_settings + _extruder_settings _disallowed_area_settings = _skirt_settings + _prime_settings + _tower_settings + _ooze_shield_settings + _distance_settings + _extruder_settings

View file

@ -88,7 +88,7 @@ class CrashHandler:
@staticmethod @staticmethod
def pruneSensitiveData(obj: Any) -> Any: def pruneSensitiveData(obj: Any) -> Any:
if isinstance(obj, str): if isinstance(obj, str):
return obj.replace(home_dir, "<user_home>") return obj.replace("\\\\", "\\").replace(home_dir, "<user_home>")
if isinstance(obj, list): if isinstance(obj, list):
return [CrashHandler.pruneSensitiveData(item) for item in obj] return [CrashHandler.pruneSensitiveData(item) for item in obj]
if isinstance(obj, dict): if isinstance(obj, dict):
@ -150,8 +150,9 @@ class CrashHandler:
self._sendCrashReport() self._sendCrashReport()
os._exit(1) os._exit(1)
## Backup the current resource directories and create clean ones.
def _backupAndStartClean(self): def _backupAndStartClean(self):
"""Backup the current resource directories and create clean ones."""
Resources.factoryReset() Resources.factoryReset()
self.early_crash_dialog.close() self.early_crash_dialog.close()
@ -162,8 +163,9 @@ class CrashHandler:
def _showDetailedReport(self): def _showDetailedReport(self):
self.dialog.exec_() self.dialog.exec_()
## Creates a modal dialog.
def _createDialog(self): def _createDialog(self):
"""Creates a modal dialog."""
self.dialog.setMinimumWidth(640) self.dialog.setMinimumWidth(640)
self.dialog.setMinimumHeight(640) self.dialog.setMinimumHeight(640)
self.dialog.setWindowTitle(catalog.i18nc("@title:window", "Crash Report")) self.dialog.setWindowTitle(catalog.i18nc("@title:window", "Crash Report"))
@ -213,6 +215,16 @@ class CrashHandler:
locale.getdefaultlocale()[0] locale.getdefaultlocale()[0]
self.data["locale_cura"] = self.cura_locale self.data["locale_cura"] = self.cura_locale
try:
from cura.CuraApplication import CuraApplication
plugins = CuraApplication.getInstance().getPluginRegistry()
self.data["plugins"] = {
plugin_id: plugins.getMetaData(plugin_id)["plugin"]["version"]
for plugin_id in plugins.getInstalledPlugins() if not plugins.isBundledPlugin(plugin_id)
}
except:
self.data["plugins"] = {"[FAILED]": "0.0.0"}
crash_info = "<b>" + catalog.i18nc("@label Cura version number", "Cura version") + ":</b> " + str(self.cura_version) + "<br/>" crash_info = "<b>" + catalog.i18nc("@label Cura version number", "Cura version") + ":</b> " + str(self.cura_version) + "<br/>"
crash_info += "<b>" + catalog.i18nc("@label", "Cura language") + ":</b> " + str(self.cura_locale) + "<br/>" crash_info += "<b>" + catalog.i18nc("@label", "Cura language") + ":</b> " + str(self.cura_locale) + "<br/>"
crash_info += "<b>" + catalog.i18nc("@label", "OS language") + ":</b> " + str(self.data["locale_os"]) + "<br/>" crash_info += "<b>" + catalog.i18nc("@label", "OS language") + ":</b> " + str(self.data["locale_os"]) + "<br/>"
@ -236,7 +248,12 @@ class CrashHandler:
scope.set_tag("locale_cura", self.cura_locale) scope.set_tag("locale_cura", self.cura_locale)
scope.set_tag("is_enterprise", ApplicationMetadata.IsEnterpriseVersion) scope.set_tag("is_enterprise", ApplicationMetadata.IsEnterpriseVersion)
scope.set_user({"id": str(uuid.getnode())}) scope.set_context("plugins", self.data["plugins"])
user_id = uuid.getnode() # On all of Cura's supported platforms, this returns the MAC address which is pseudonymical information (!= anonymous).
user_id %= 2 ** 16 # So to make it anonymous, apply a bitmask selecting only the last 16 bits.
# This prevents it from being traceable to a specific user but still gives somewhat of an idea of whether it's just the same user hitting the same crash over and over again, or if it's widespread.
scope.set_user({"id": str(user_id)})
return group return group

View file

@ -40,12 +40,13 @@ class CuraActions(QObject):
@pyqtSlot() @pyqtSlot()
def openBugReportPage(self) -> None: def openBugReportPage(self) -> None:
event = CallFunctionEvent(self._openUrl, [QUrl("https://github.com/Ultimaker/Cura/issues")], {}) event = CallFunctionEvent(self._openUrl, [QUrl("https://github.com/Ultimaker/Cura/issues/new/choose")], {})
cura.CuraApplication.CuraApplication.getInstance().functionEvent(event) cura.CuraApplication.CuraApplication.getInstance().functionEvent(event)
## Reset camera position and direction to default
@pyqtSlot() @pyqtSlot()
def homeCamera(self) -> None: def homeCamera(self) -> None:
"""Reset camera position and direction to default"""
scene = cura.CuraApplication.CuraApplication.getInstance().getController().getScene() scene = cura.CuraApplication.CuraApplication.getInstance().getController().getScene()
camera = scene.getActiveCamera() camera = scene.getActiveCamera()
if camera: if camera:
@ -54,9 +55,10 @@ class CuraActions(QObject):
camera.setPerspective(True) camera.setPerspective(True)
camera.lookAt(Vector(0, 0, 0)) camera.lookAt(Vector(0, 0, 0))
## Center all objects in the selection
@pyqtSlot() @pyqtSlot()
def centerSelection(self) -> None: def centerSelection(self) -> None:
"""Center all objects in the selection"""
operation = GroupedOperation() operation = GroupedOperation()
for node in Selection.getAllSelectedObjects(): for node in Selection.getAllSelectedObjects():
current_node = node current_node = node
@ -65,26 +67,33 @@ class CuraActions(QObject):
current_node = parent_node current_node = parent_node
parent_node = current_node.getParent() parent_node = current_node.getParent()
# This was formerly done with SetTransformOperation but because of # Find out where the bottom of the object is
# unpredictable matrix deconstruction it was possible that mirrors bbox = current_node.getBoundingBox()
# could manifest as rotations. Centering is therefore done by if bbox:
# moving the node to negative whatever its position is: center_y = current_node.getWorldPosition().y - bbox.bottom
center_operation = TranslateOperation(current_node, -current_node._position) else:
center_y = 0
# Move the object so that it's bottom is on to of the buildplate
center_operation = TranslateOperation(current_node, Vector(0, center_y, 0), set_position = True)
operation.addOperation(center_operation) operation.addOperation(center_operation)
operation.push() operation.push()
## Multiply all objects in the selection
#
# \param count The number of times to multiply the selection.
@pyqtSlot(int) @pyqtSlot(int)
def multiplySelection(self, count: int) -> None: def multiplySelection(self, count: int) -> None:
"""Multiply all objects in the selection
:param count: The number of times to multiply the selection.
"""
min_offset = cura.CuraApplication.CuraApplication.getInstance().getBuildVolume().getEdgeDisallowedSize() + 2 # Allow for some rounding errors min_offset = cura.CuraApplication.CuraApplication.getInstance().getBuildVolume().getEdgeDisallowedSize() + 2 # Allow for some rounding errors
job = MultiplyObjectsJob(Selection.getAllSelectedObjects(), count, min_offset = max(min_offset, 8)) job = MultiplyObjectsJob(Selection.getAllSelectedObjects(), count, min_offset = max(min_offset, 8))
job.start() job.start()
## Delete all selected objects.
@pyqtSlot() @pyqtSlot()
def deleteSelection(self) -> None: def deleteSelection(self) -> None:
"""Delete all selected objects."""
if not cura.CuraApplication.CuraApplication.getInstance().getController().getToolsEnabled(): if not cura.CuraApplication.CuraApplication.getInstance().getController().getToolsEnabled():
return return
@ -106,11 +115,13 @@ class CuraActions(QObject):
op.push() op.push()
## Set the extruder that should be used to print the selection.
#
# \param extruder_id The ID of the extruder stack to use for the selected objects.
@pyqtSlot(str) @pyqtSlot(str)
def setExtruderForSelection(self, extruder_id: str) -> None: def setExtruderForSelection(self, extruder_id: str) -> None:
"""Set the extruder that should be used to print the selection.
:param extruder_id: The ID of the extruder stack to use for the selected objects.
"""
operation = GroupedOperation() operation = GroupedOperation()
nodes_to_change = [] nodes_to_change = []

File diff suppressed because it is too large Load diff

View file

@ -24,11 +24,15 @@ class CuraPackageManager(PackageManager):
super().initialize() super().initialize()
## Returns a list of where the package is used
# empty if it is never used.
# It loops through all the package contents and see if some of the ids are used.
# The list consists of 3-tuples: (global_stack, extruder_nr, container_id)
def getMachinesUsingPackage(self, package_id: str) -> Tuple[List[Tuple[GlobalStack, str, str]], List[Tuple[GlobalStack, str, str]]]: def getMachinesUsingPackage(self, package_id: str) -> Tuple[List[Tuple[GlobalStack, str, str]], List[Tuple[GlobalStack, str, str]]]:
"""Returns a list of where the package is used
It loops through all the package contents and see if some of the ids are used.
:param package_id: package id to search for
:return: empty if it is never used, otherwise a list consisting of 3-tuples
"""
ids = self.getPackageContainerIds(package_id) ids = self.getPackageContainerIds(package_id)
container_stacks = self._application.getContainerRegistry().findContainerStacks() container_stacks = self._application.getContainerRegistry().findContainerStacks()
global_stacks = [container_stack for container_stack in container_stacks if isinstance(container_stack, GlobalStack)] global_stacks = [container_stack for container_stack in container_stacks if isinstance(container_stack, GlobalStack)]
@ -36,10 +40,10 @@ class CuraPackageManager(PackageManager):
machine_with_qualities = [] machine_with_qualities = []
for container_id in ids: for container_id in ids:
for global_stack in global_stacks: for global_stack in global_stacks:
for extruder_nr, extruder_stack in global_stack.extruders.items(): for extruder_nr, extruder_stack in enumerate(global_stack.extruderList):
if container_id in (extruder_stack.material.getId(), extruder_stack.material.getMetaData().get("base_file")): if container_id in (extruder_stack.material.getId(), extruder_stack.material.getMetaData().get("base_file")):
machine_with_materials.append((global_stack, extruder_nr, container_id)) machine_with_materials.append((global_stack, str(extruder_nr), container_id))
if container_id == extruder_stack.quality.getId(): if container_id == extruder_stack.quality.getId():
machine_with_qualities.append((global_stack, extruder_nr, container_id)) machine_with_qualities.append((global_stack, str(extruder_nr), container_id))
return machine_with_materials, machine_with_qualities return machine_with_materials, machine_with_qualities

View file

@ -10,3 +10,4 @@ CuraCloudAPIRoot = "@CURA_CLOUD_API_ROOT@"
CuraCloudAPIVersion = "@CURA_CLOUD_API_VERSION@" CuraCloudAPIVersion = "@CURA_CLOUD_API_VERSION@"
CuraCloudAccountAPIRoot = "@CURA_CLOUD_ACCOUNT_API_ROOT@" CuraCloudAccountAPIRoot = "@CURA_CLOUD_ACCOUNT_API_ROOT@"
CuraMarketplaceRoot = "@CURA_MARKETPLACE_ROOT@" CuraMarketplaceRoot = "@CURA_MARKETPLACE_ROOT@"
CuraDigitalFactoryURL = "@CURA_DIGITAL_FACTORY_URL@"

View file

@ -3,9 +3,12 @@
from UM.Mesh.MeshData import MeshData from UM.Mesh.MeshData import MeshData
## Class to holds the layer mesh and information about the layers.
# Immutable, use LayerDataBuilder to create one of these.
class LayerData(MeshData): class LayerData(MeshData):
"""Class to holds the layer mesh and information about the layers.
Immutable, use :py:class:`cura.LayerDataBuilder.LayerDataBuilder` to create one of these.
"""
def __init__(self, vertices = None, normals = None, indices = None, colors = None, uvs = None, file_name = None, def __init__(self, vertices = None, normals = None, indices = None, colors = None, uvs = None, file_name = None,
center_position = None, layers=None, element_counts=None, attributes=None): center_position = None, layers=None, element_counts=None, attributes=None):
super().__init__(vertices=vertices, normals=normals, indices=indices, colors=colors, uvs=uvs, super().__init__(vertices=vertices, normals=normals, indices=indices, colors=colors, uvs=uvs,

View file

@ -10,8 +10,9 @@ import numpy
from typing import Dict, Optional from typing import Dict, Optional
## Builder class for constructing a LayerData object
class LayerDataBuilder(MeshBuilder): class LayerDataBuilder(MeshBuilder):
"""Builder class for constructing a :py:class:`cura.LayerData.LayerData` object"""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._layers = {} # type: Dict[int, Layer] self._layers = {} # type: Dict[int, Layer]
@ -42,11 +43,13 @@ class LayerDataBuilder(MeshBuilder):
self._layers[layer].setThickness(thickness) self._layers[layer].setThickness(thickness)
## Return the layer data as LayerData.
#
# \param material_color_map: [r, g, b, a] for each extruder row.
# \param line_type_brightness: compatibility layer view uses line type brightness of 0.5
def build(self, material_color_map, line_type_brightness = 1.0): def build(self, material_color_map, line_type_brightness = 1.0):
"""Return the layer data as :py:class:`cura.LayerData.LayerData`.
:param material_color_map: [r, g, b, a] for each extruder row.
:param line_type_brightness: compatibility layer view uses line type brightness of 0.5
"""
vertex_count = 0 vertex_count = 0
index_count = 0 index_count = 0
for layer, data in self._layers.items(): for layer, data in self._layers.items():

View file

@ -7,8 +7,9 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
from cura.LayerData import LayerData from cura.LayerData import LayerData
## Simple decorator to indicate a scene node holds layer data.
class LayerDataDecorator(SceneNodeDecorator): class LayerDataDecorator(SceneNodeDecorator):
"""Simple decorator to indicate a scene node holds layer data."""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._layer_data = None # type: Optional[LayerData] self._layer_data = None # type: Optional[LayerData]

View file

@ -26,14 +26,17 @@ class LayerPolygon:
__jump_map = numpy.logical_or(numpy.logical_or(numpy.arange(__number_of_types) == NoneType, numpy.arange(__number_of_types) == MoveCombingType), numpy.arange(__number_of_types) == MoveRetractionType) __jump_map = numpy.logical_or(numpy.logical_or(numpy.arange(__number_of_types) == NoneType, numpy.arange(__number_of_types) == MoveCombingType), numpy.arange(__number_of_types) == MoveRetractionType)
## LayerPolygon, used in ProcessSlicedLayersJob
# \param extruder The position of the extruder
# \param line_types array with line_types
# \param data new_points
# \param line_widths array with line widths
# \param line_thicknesses: array with type as index and thickness as value
# \param line_feedrates array with line feedrates
def __init__(self, extruder: int, line_types: numpy.ndarray, data: numpy.ndarray, line_widths: numpy.ndarray, line_thicknesses: numpy.ndarray, line_feedrates: numpy.ndarray) -> None: def __init__(self, extruder: int, line_types: numpy.ndarray, data: numpy.ndarray, line_widths: numpy.ndarray, line_thicknesses: numpy.ndarray, line_feedrates: numpy.ndarray) -> None:
"""LayerPolygon, used in ProcessSlicedLayersJob
:param extruder: The position of the extruder
:param line_types: array with line_types
:param data: new_points
:param line_widths: array with line widths
:param line_thicknesses: array with type as index and thickness as value
:param line_feedrates: array with line feedrates
"""
self._extruder = extruder self._extruder = extruder
self._types = line_types self._types = line_types
for i in range(len(self._types)): for i in range(len(self._types)):
@ -62,7 +65,7 @@ class LayerPolygon:
# When type is used as index returns true if type == LayerPolygon.InfillType or type == LayerPolygon.SkinType or type == LayerPolygon.SupportInfillType # When type is used as index returns true if type == LayerPolygon.InfillType or type == LayerPolygon.SkinType or type == LayerPolygon.SupportInfillType
# Should be generated in better way, not hardcoded. # Should be generated in better way, not hardcoded.
self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = numpy.bool) self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = bool)
self._build_cache_line_mesh_mask = None # type: Optional[numpy.ndarray] self._build_cache_line_mesh_mask = None # type: Optional[numpy.ndarray]
self._build_cache_needed_points = None # type: Optional[numpy.ndarray] self._build_cache_needed_points = None # type: Optional[numpy.ndarray]
@ -70,32 +73,34 @@ class LayerPolygon:
def buildCache(self) -> None: def buildCache(self) -> None:
# For the line mesh we do not draw Infill or Jumps. Therefore those lines are filtered out. # For the line mesh we do not draw Infill or Jumps. Therefore those lines are filtered out.
self._build_cache_line_mesh_mask = numpy.ones(self._jump_mask.shape, dtype = bool) self._build_cache_line_mesh_mask = numpy.ones(self._jump_mask.shape, dtype = bool)
mesh_line_count = numpy.sum(self._build_cache_line_mesh_mask)
self._index_begin = 0 self._index_begin = 0
self._index_end = mesh_line_count self._index_end = cast(int, numpy.sum(self._build_cache_line_mesh_mask))
self._build_cache_needed_points = numpy.ones((len(self._types), 2), dtype = numpy.bool) self._build_cache_needed_points = numpy.ones((len(self._types), 2), dtype = bool)
# Only if the type of line segment changes do we need to add an extra vertex to change colors # Only if the type of line segment changes do we need to add an extra vertex to change colors
self._build_cache_needed_points[1:, 0][:, numpy.newaxis] = self._types[1:] != self._types[:-1] self._build_cache_needed_points[1:, 0][:, numpy.newaxis] = self._types[1:] != self._types[:-1]
# Mark points as unneeded if they are of types we don't want in the line mesh according to the calculated mask # Mark points as unneeded if they are of types we don't want in the line mesh according to the calculated mask
numpy.logical_and(self._build_cache_needed_points, self._build_cache_line_mesh_mask, self._build_cache_needed_points ) numpy.logical_and(self._build_cache_needed_points, self._build_cache_line_mesh_mask, self._build_cache_needed_points )
self._vertex_begin = 0 self._vertex_begin = 0
self._vertex_end = numpy.sum( self._build_cache_needed_points ) self._vertex_end = cast(int, numpy.sum(self._build_cache_needed_points))
## Set all the arrays provided by the function caller, representing the LayerPolygon
# The arrays are either by vertex or by indices.
#
# \param vertex_offset : determines where to start and end filling the arrays
# \param index_offset : determines where to start and end filling the arrays
# \param vertices : vertex numpy array to be filled
# \param colors : vertex numpy array to be filled
# \param line_dimensions : vertex numpy array to be filled
# \param feedrates : vertex numpy array to be filled
# \param extruders : vertex numpy array to be filled
# \param line_types : vertex numpy array to be filled
# \param indices : index numpy array to be filled
def build(self, vertex_offset: int, index_offset: int, vertices: numpy.ndarray, colors: numpy.ndarray, line_dimensions: numpy.ndarray, feedrates: numpy.ndarray, extruders: numpy.ndarray, line_types: numpy.ndarray, indices: numpy.ndarray) -> None: def build(self, vertex_offset: int, index_offset: int, vertices: numpy.ndarray, colors: numpy.ndarray, line_dimensions: numpy.ndarray, feedrates: numpy.ndarray, extruders: numpy.ndarray, line_types: numpy.ndarray, indices: numpy.ndarray) -> None:
"""Set all the arrays provided by the function caller, representing the LayerPolygon
The arrays are either by vertex or by indices.
:param vertex_offset: determines where to start and end filling the arrays
:param index_offset: determines where to start and end filling the arrays
:param vertices: vertex numpy array to be filled
:param colors: vertex numpy array to be filled
:param line_dimensions: vertex numpy array to be filled
:param feedrates: vertex numpy array to be filled
:param extruders: vertex numpy array to be filled
:param line_types: vertex numpy array to be filled
:param indices: index numpy array to be filled
"""
if self._build_cache_line_mesh_mask is None or self._build_cache_needed_points is None: if self._build_cache_line_mesh_mask is None or self._build_cache_needed_points is None:
self.buildCache() self.buildCache()
@ -202,8 +207,12 @@ class LayerPolygon:
def jumpCount(self): def jumpCount(self):
return self._jump_count return self._jump_count
# Calculate normals for the entire polygon using numpy.
def getNormals(self) -> numpy.ndarray: def getNormals(self) -> numpy.ndarray:
"""Calculate normals for the entire polygon using numpy.
:return: normals for the entire polygon
"""
normals = numpy.copy(self._data) normals = numpy.copy(self._data)
normals[:, 1] = 0.0 # We are only interested in 2D normals normals[:, 1] = 0.0 # We are only interested in 2D normals
@ -229,9 +238,10 @@ class LayerPolygon:
__color_map = None # type: numpy.ndarray __color_map = None # type: numpy.ndarray
## Gets the instance of the VersionUpgradeManager, or creates one.
@classmethod @classmethod
def getColorMap(cls) -> numpy.ndarray: def getColorMap(cls) -> numpy.ndarray:
"""Gets the instance of the VersionUpgradeManager, or creates one."""
if cls.__color_map is None: if cls.__color_map is None:
theme = cast(Theme, QtApplication.getInstance().getTheme()) theme = cast(Theme, QtApplication.getInstance().getTheme())
cls.__color_map = numpy.array([ cls.__color_map = numpy.array([

View file

@ -11,16 +11,22 @@ from UM.PluginObject import PluginObject
from UM.PluginRegistry import PluginRegistry from UM.PluginRegistry import PluginRegistry
## Machine actions are actions that are added to a specific machine type. Examples of such actions are
# updating the firmware, connecting with remote devices or doing bed leveling. A machine action can also have a
# qml, which should contain a "Cura.MachineAction" item. When activated, the item will be displayed in a dialog
# and this object will be added as "manager" (so all pyqtSlot() functions can be called by calling manager.func())
class MachineAction(QObject, PluginObject): class MachineAction(QObject, PluginObject):
"""Machine actions are actions that are added to a specific machine type.
Examples of such actions are updating the firmware, connecting with remote devices or doing bed leveling. A
machine action can also have a qml, which should contain a :py:class:`cura.MachineAction.MachineAction` item.
When activated, the item will be displayed in a dialog and this object will be added as "manager" (so all
pyqtSlot() functions can be called by calling manager.func())
"""
## Create a new Machine action.
# \param key unique key of the machine action
# \param label Human readable label used to identify the machine action.
def __init__(self, key: str, label: str = "") -> None: def __init__(self, key: str, label: str = "") -> None:
"""Create a new Machine action.
:param key: unique key of the machine action
:param label: Human readable label used to identify the machine action.
"""
super().__init__() super().__init__()
self._key = key self._key = key
self._label = label self._label = label
@ -34,10 +40,14 @@ class MachineAction(QObject, PluginObject):
def getKey(self) -> str: def getKey(self) -> str:
return self._key return self._key
## Whether this action needs to ask the user anything.
# If not, we shouldn't present the user with certain screens which otherwise show up.
# Defaults to true to be in line with the old behaviour.
def needsUserInteraction(self) -> bool: def needsUserInteraction(self) -> bool:
"""Whether this action needs to ask the user anything.
If not, we shouldn't present the user with certain screens which otherwise show up.
:return: Defaults to true to be in line with the old behaviour.
"""
return True return True
@pyqtProperty(str, notify = labelChanged) @pyqtProperty(str, notify = labelChanged)
@ -49,17 +59,24 @@ class MachineAction(QObject, PluginObject):
self._label = label self._label = label
self.labelChanged.emit() self.labelChanged.emit()
## Reset the action to it's default state.
# This should not be re-implemented by child classes, instead re-implement _reset.
# /sa _reset
@pyqtSlot() @pyqtSlot()
def reset(self) -> None: def reset(self) -> None:
"""Reset the action to it's default state.
This should not be re-implemented by child classes, instead re-implement _reset.
:py:meth:`cura.MachineAction.MachineAction._reset`
"""
self._finished = False self._finished = False
self._reset() self._reset()
## Protected implementation of reset.
# /sa reset()
def _reset(self) -> None: def _reset(self) -> None:
"""Protected implementation of reset.
See also :py:meth:`cura.MachineAction.MachineAction.reset`
"""
pass pass
@pyqtSlot() @pyqtSlot()
@ -72,8 +89,9 @@ class MachineAction(QObject, PluginObject):
def finished(self) -> bool: def finished(self) -> bool:
return self._finished return self._finished
## Protected helper to create a view object based on provided QML.
def _createViewFromQML(self) -> Optional["QObject"]: def _createViewFromQML(self) -> Optional["QObject"]:
"""Protected helper to create a view object based on provided QML."""
plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId())
if plugin_path is None: if plugin_path is None:
Logger.log("e", "Cannot create QML view: cannot find plugin path for plugin [%s]", self.getPluginId()) Logger.log("e", "Cannot create QML view: cannot find plugin path for plugin [%s]", self.getPluginId())

View file

@ -9,47 +9,59 @@ from UM.Logger import Logger
from UM.Settings.InstanceContainer import InstanceContainer from UM.Settings.InstanceContainer import InstanceContainer
## A node in the container tree. It represents one container.
#
# The container it represents is referenced by its container_id. During normal
# use of the tree, this container is not constructed. Only when parts of the
# tree need to get loaded in the container stack should it get constructed.
class ContainerNode: class ContainerNode:
## Creates a new node for the container tree. """A node in the container tree. It represents one container.
# \param container_id The ID of the container that this node should
# represent. The container it represents is referenced by its container_id. During normal use of the tree, this container is
not constructed. Only when parts of the tree need to get loaded in the container stack should it get constructed.
"""
def __init__(self, container_id: str) -> None: def __init__(self, container_id: str) -> None:
"""Creates a new node for the container tree.
:param container_id: The ID of the container that this node should represent.
"""
self.container_id = container_id self.container_id = container_id
self._container = None # type: Optional[InstanceContainer] self._container = None # type: Optional[InstanceContainer]
self.children_map = {} # type: Dict[str, ContainerNode] # Mapping from container ID to container node. self.children_map = {} # type: Dict[str, ContainerNode] # Mapping from container ID to container node.
## Gets the metadata of the container that this node represents.
# Getting the metadata from the container directly is about 10x as fast.
# \return The metadata of the container in this node.
def getMetadata(self) -> Dict[str, Any]: def getMetadata(self) -> Dict[str, Any]:
"""Gets the metadata of the container that this node represents.
Getting the metadata from the container directly is about 10x as fast.
:return: The metadata of the container in this node.
"""
return ContainerRegistry.getInstance().findContainersMetadata(id = self.container_id)[0] return ContainerRegistry.getInstance().findContainersMetadata(id = self.container_id)[0]
## Get an entry from the metadata of the container that this node contains.
#
# This is just a convenience function.
# \param entry The metadata entry key to return.
# \param default If the metadata is not present or the container is not
# found, the value of this default is returned.
# \return The value of the metadata entry, or the default if it was not
# present.
def getMetaDataEntry(self, entry: str, default: Any = None) -> Any: def getMetaDataEntry(self, entry: str, default: Any = None) -> Any:
"""Get an entry from the metadata of the container that this node contains.
This is just a convenience function.
:param entry: The metadata entry key to return.
:param default: If the metadata is not present or the container is not found, the value of this default is
returned.
:return: The value of the metadata entry, or the default if it was not present.
"""
container_metadata = ContainerRegistry.getInstance().findContainersMetadata(id = self.container_id) container_metadata = ContainerRegistry.getInstance().findContainersMetadata(id = self.container_id)
if len(container_metadata) == 0: if len(container_metadata) == 0:
return default return default
return container_metadata[0].get(entry, default) return container_metadata[0].get(entry, default)
## The container that this node's container ID refers to.
#
# This can be used to finally instantiate the container in order to put it
# in the container stack.
# \return A container.
@property @property
def container(self) -> Optional[InstanceContainer]: def container(self) -> Optional[InstanceContainer]:
"""The container that this node's container ID refers to.
This can be used to finally instantiate the container in order to put it in the container stack.
:return: A container.
"""
if not self._container: if not self._container:
container_list = ContainerRegistry.getInstance().findInstanceContainers(id = self.container_id) container_list = ContainerRegistry.getInstance().findInstanceContainers(id = self.container_id)
if len(container_list) == 0: if len(container_list) == 0:

View file

@ -19,17 +19,16 @@ if TYPE_CHECKING:
from UM.Settings.ContainerStack import ContainerStack from UM.Settings.ContainerStack import ContainerStack
## This class contains a look-up tree for which containers are available at
# which stages of configuration.
#
# The tree starts at the machine definitions. For every distinct definition
# there will be one machine node here.
#
# All of the fallbacks for material choices, quality choices, etc. should be
# encoded in this tree. There must always be at least one child node (for
# nodes that have children) but that child node may be a node representing the
# empty instance container.
class ContainerTree: class ContainerTree:
"""This class contains a look-up tree for which containers are available at which stages of configuration.
The tree starts at the machine definitions. For every distinct definition there will be one machine node here.
All of the fallbacks for material choices, quality choices, etc. should be encoded in this tree. There must
always be at least one child node (for nodes that have children) but that child node may be a node representing
the empty instance container.
"""
__instance = None # type: Optional["ContainerTree"] __instance = None # type: Optional["ContainerTree"]
@classmethod @classmethod
@ -43,13 +42,15 @@ class ContainerTree:
self.materialsChanged = Signal() # Emitted when any of the material nodes in the tree got changed. self.materialsChanged = Signal() # Emitted when any of the material nodes in the tree got changed.
cura.CuraApplication.CuraApplication.getInstance().initializationFinished.connect(self._onStartupFinished) # Start the background task to load more machine nodes after start-up is completed. cura.CuraApplication.CuraApplication.getInstance().initializationFinished.connect(self._onStartupFinished) # Start the background task to load more machine nodes after start-up is completed.
## Get the quality groups available for the currently activated printer.
#
# This contains all quality groups, enabled or disabled. To check whether
# the quality group can be activated, test for the
# ``QualityGroup.is_available`` property.
# \return For every quality type, one quality group.
def getCurrentQualityGroups(self) -> Dict[str, "QualityGroup"]: def getCurrentQualityGroups(self) -> Dict[str, "QualityGroup"]:
"""Get the quality groups available for the currently activated printer.
This contains all quality groups, enabled or disabled. To check whether the quality group can be activated,
test for the ``QualityGroup.is_available`` property.
:return: For every quality type, one quality group.
"""
global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if global_stack is None: if global_stack is None:
return {} return {}
@ -58,14 +59,15 @@ class ContainerTree:
extruder_enabled = [extruder.isEnabled for extruder in global_stack.extruderList] extruder_enabled = [extruder.isEnabled for extruder in global_stack.extruderList]
return self.machines[global_stack.definition.getId()].getQualityGroups(variant_names, material_bases, extruder_enabled) return self.machines[global_stack.definition.getId()].getQualityGroups(variant_names, material_bases, extruder_enabled)
## Get the quality changes groups available for the currently activated
# printer.
#
# This contains all quality changes groups, enabled or disabled. To check
# whether the quality changes group can be activated, test for the
# ``QualityChangesGroup.is_available`` property.
# \return A list of all quality changes groups.
def getCurrentQualityChangesGroups(self) -> List["QualityChangesGroup"]: def getCurrentQualityChangesGroups(self) -> List["QualityChangesGroup"]:
"""Get the quality changes groups available for the currently activated printer.
This contains all quality changes groups, enabled or disabled. To check whether the quality changes group can
be activated, test for the ``QualityChangesGroup.is_available`` property.
:return: A list of all quality changes groups.
"""
global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if global_stack is None: if global_stack is None:
return [] return []
@ -74,31 +76,43 @@ class ContainerTree:
extruder_enabled = [extruder.isEnabled for extruder in global_stack.extruderList] extruder_enabled = [extruder.isEnabled for extruder in global_stack.extruderList]
return self.machines[global_stack.definition.getId()].getQualityChangesGroups(variant_names, material_bases, extruder_enabled) return self.machines[global_stack.definition.getId()].getQualityChangesGroups(variant_names, material_bases, extruder_enabled)
## Ran after completely starting up the application.
def _onStartupFinished(self) -> None: def _onStartupFinished(self) -> None:
"""Ran after completely starting up the application."""
currently_added = ContainerRegistry.getInstance().findContainerStacks() # Find all currently added global stacks. currently_added = ContainerRegistry.getInstance().findContainerStacks() # Find all currently added global stacks.
JobQueue.getInstance().add(self._MachineNodeLoadJob(self, currently_added)) JobQueue.getInstance().add(self._MachineNodeLoadJob(self, currently_added))
## Dictionary-like object that contains the machines.
#
# This handles the lazy loading of MachineNodes.
class _MachineNodeMap: class _MachineNodeMap:
"""Dictionary-like object that contains the machines.
This handles the lazy loading of MachineNodes.
"""
def __init__(self) -> None: def __init__(self) -> None:
self._machines = {} # type: Dict[str, MachineNode] self._machines = {} # type: Dict[str, MachineNode]
## Returns whether a printer with a certain definition ID exists. This
# is regardless of whether or not the printer is loaded yet.
# \param definition_id The definition to look for.
# \return Whether or not a printer definition exists with that name.
def __contains__(self, definition_id: str) -> bool: def __contains__(self, definition_id: str) -> bool:
"""Returns whether a printer with a certain definition ID exists.
This is regardless of whether or not the printer is loaded yet.
:param definition_id: The definition to look for.
:return: Whether or not a printer definition exists with that name.
"""
return len(ContainerRegistry.getInstance().findContainersMetadata(id = definition_id)) > 0 return len(ContainerRegistry.getInstance().findContainersMetadata(id = definition_id)) > 0
## Returns a machine node for the specified definition ID.
#
# If the machine node wasn't loaded yet, this will load it lazily.
# \param definition_id The definition to look for.
# \return A machine node for that definition.
def __getitem__(self, definition_id: str) -> MachineNode: def __getitem__(self, definition_id: str) -> MachineNode:
"""Returns a machine node for the specified definition ID.
If the machine node wasn't loaded yet, this will load it lazily.
:param definition_id: The definition to look for.
:return: A machine node for that definition.
"""
if definition_id not in self._machines: if definition_id not in self._machines:
start_time = time.time() start_time = time.time()
self._machines[definition_id] = MachineNode(definition_id) self._machines[definition_id] = MachineNode(definition_id)
@ -106,46 +120,58 @@ class ContainerTree:
Logger.log("d", "Adding container tree for {definition_id} took {duration} seconds.".format(definition_id = definition_id, duration = time.time() - start_time)) Logger.log("d", "Adding container tree for {definition_id} took {duration} seconds.".format(definition_id = definition_id, duration = time.time() - start_time))
return self._machines[definition_id] return self._machines[definition_id]
## Gets a machine node for the specified definition ID, with default.
#
# The default is returned if there is no definition with the specified
# ID. If the machine node wasn't loaded yet, this will load it lazily.
# \param definition_id The definition to look for.
# \param default The machine node to return if there is no machine
# with that definition (can be ``None`` optionally or if not
# provided).
# \return A machine node for that definition, or the default if there
# is no definition with the provided definition_id.
def get(self, definition_id: str, default: Optional[MachineNode] = None) -> Optional[MachineNode]: def get(self, definition_id: str, default: Optional[MachineNode] = None) -> Optional[MachineNode]:
"""Gets a machine node for the specified definition ID, with default.
The default is returned if there is no definition with the specified ID. If the machine node wasn't
loaded yet, this will load it lazily.
:param definition_id: The definition to look for.
:param default: The machine node to return if there is no machine with that definition (can be ``None``
optionally or if not provided).
:return: A machine node for that definition, or the default if there is no definition with the provided
definition_id.
"""
if definition_id not in self: if definition_id not in self:
return default return default
return self[definition_id] return self[definition_id]
## Returns whether we've already cached this definition's node.
# \param definition_id The definition that we may have cached.
# \return ``True`` if it's cached.
def is_loaded(self, definition_id: str) -> bool: def is_loaded(self, definition_id: str) -> bool:
"""Returns whether we've already cached this definition's node.
:param definition_id: The definition that we may have cached.
:return: ``True`` if it's cached.
"""
return definition_id in self._machines return definition_id in self._machines
## Pre-loads all currently added printers as a background task so that
# switching printers in the interface is faster.
class _MachineNodeLoadJob(Job): class _MachineNodeLoadJob(Job):
## Creates a new background task. """Pre-loads all currently added printers as a background task so that switching printers in the interface is
# \param tree_root The container tree instance. This cannot be faster.
# obtained through the singleton static function since the instance """
# may not yet be constructed completely.
# \param container_stacks All of the stacks to pre-load the container
# trees for. This needs to be provided from here because the stacks
# need to be constructed on the main thread because they are QObject.
def __init__(self, tree_root: "ContainerTree", container_stacks: List["ContainerStack"]) -> None: def __init__(self, tree_root: "ContainerTree", container_stacks: List["ContainerStack"]) -> None:
"""Creates a new background task.
:param tree_root: The container tree instance. This cannot be obtained through the singleton static
function since the instance may not yet be constructed completely.
:param container_stacks: All of the stacks to pre-load the container trees for. This needs to be provided
from here because the stacks need to be constructed on the main thread because they are QObject.
"""
self.tree_root = tree_root self.tree_root = tree_root
self.container_stacks = container_stacks self.container_stacks = container_stacks
super().__init__() super().__init__()
## Starts the background task.
#
# The ``JobQueue`` will schedule this on a different thread.
def run(self) -> None: def run(self) -> None:
"""Starts the background task.
The ``JobQueue`` will schedule this on a different thread.
"""
Logger.log("d", "Started background loading of MachineNodes")
for stack in self.container_stacks: # Load all currently-added containers. for stack in self.container_stacks: # Load all currently-added containers.
if not isinstance(stack, GlobalStack): if not isinstance(stack, GlobalStack):
continue continue
@ -156,3 +182,4 @@ class ContainerTree:
definition_id = stack.definition.getId() definition_id = stack.definition.getId()
if not self.tree_root.machines.is_loaded(definition_id): if not self.tree_root.machines.is_loaded(definition_id):
_ = self.tree_root.machines[definition_id] _ = self.tree_root.machines[definition_id]
Logger.log("d", "All MachineNode loading completed")

View file

@ -11,10 +11,12 @@ if TYPE_CHECKING:
from cura.Machines.QualityNode import QualityNode from cura.Machines.QualityNode import QualityNode
## This class represents an intent profile in the container tree.
#
# This class has no more subnodes.
class IntentNode(ContainerNode): class IntentNode(ContainerNode):
"""This class represents an intent profile in the container tree.
This class has no more subnodes.
"""
def __init__(self, container_id: str, quality: "QualityNode") -> None: def __init__(self, container_id: str, quality: "QualityNode") -> None:
super().__init__(container_id) super().__init__(container_id)
self.quality = quality self.quality = quality

View file

@ -1,4 +1,4 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import time import time
@ -13,16 +13,17 @@ from UM.Settings.SettingDefinition import SettingDefinition
from UM.Settings.Validator import ValidatorState from UM.Settings.Validator import ValidatorState
import cura.CuraApplication import cura.CuraApplication
#
# This class performs setting error checks for the currently active machine.
#
# The whole error checking process is pretty heavy which can take ~0.5 secs, so it can cause GUI to lag.
# The idea here is to split the whole error check into small tasks, each of which only checks a single setting key
# in a stack. According to my profiling results, the maximal runtime for such a sub-task is <0.03 secs, which should
# be good enough. Moreover, if any changes happened to the machine, we can cancel the check in progress without wait
# for it to finish the complete work.
#
class MachineErrorChecker(QObject): class MachineErrorChecker(QObject):
"""This class performs setting error checks for the currently active machine.
The whole error checking process is pretty heavy which can take ~0.5 secs, so it can cause GUI to lag. The idea
here is to split the whole error check into small tasks, each of which only checks a single setting key in a
stack. According to my profiling results, the maximal runtime for such a sub-task is <0.03 secs, which should be
good enough. Moreover, if any changes happened to the machine, we can cancel the check in progress without wait
for it to finish the complete work.
"""
def __init__(self, parent: Optional[QObject] = None) -> None: def __init__(self, parent: Optional[QObject] = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -50,6 +51,8 @@ class MachineErrorChecker(QObject):
self._error_check_timer.setInterval(100) self._error_check_timer.setInterval(100)
self._error_check_timer.setSingleShot(True) self._error_check_timer.setSingleShot(True)
self._keys_to_check = set() # type: Set[str]
def initialize(self) -> None: def initialize(self) -> None:
self._error_check_timer.timeout.connect(self._rescheduleCheck) self._error_check_timer.timeout.connect(self._rescheduleCheck)
@ -92,24 +95,38 @@ class MachineErrorChecker(QObject):
def needToWaitForResult(self) -> bool: def needToWaitForResult(self) -> bool:
return self._need_to_check or self._check_in_progress return self._need_to_check or self._check_in_progress
# Start the error check for property changed
# this is seperate from the startErrorCheck because it ignores a number property types
def startErrorCheckPropertyChanged(self, key: str, property_name: str) -> None: 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
:param key:
:param property_name:
"""
if property_name != "value": if property_name != "value":
return return
self._keys_to_check.add(key)
self.startErrorCheck() self.startErrorCheck()
# Starts the error check timer to schedule a new error check.
def startErrorCheck(self, *args: Any) -> None: def startErrorCheck(self, *args: Any) -> None:
"""Starts the error check timer to schedule a new error check.
:param args:
"""
if not self._check_in_progress: if not self._check_in_progress:
self._need_to_check = True self._need_to_check = True
self.needToWaitForResultChanged.emit() self.needToWaitForResultChanged.emit()
self._error_check_timer.start() self._error_check_timer.start()
# This function is called by the timer to reschedule a new error check.
# If there is no check in progress, it will start a new one. If there is any, it sets the "_need_to_check" flag
# to notify the current check to stop and start a new one.
def _rescheduleCheck(self) -> None: def _rescheduleCheck(self) -> None:
"""This function is called by the timer to reschedule a new error check.
If there is no check in progress, it will start a new one. If there is any, it sets the "_need_to_check" flag
to notify the current check to stop and start a new one.
"""
if self._check_in_progress and not self._need_to_check: if self._check_in_progress and not self._need_to_check:
self._need_to_check = True self._need_to_check = True
self.needToWaitForResultChanged.emit() self.needToWaitForResultChanged.emit()
@ -127,7 +144,10 @@ class MachineErrorChecker(QObject):
# Populate the (stack, key) tuples to check # Populate the (stack, key) tuples to check
self._stacks_and_keys_to_check = deque() self._stacks_and_keys_to_check = deque()
for stack in global_stack.extruderList: for stack in global_stack.extruderList:
for key in stack.getAllKeys(): if not self._keys_to_check:
self._keys_to_check = stack.getAllKeys()
for key in self._keys_to_check:
self._stacks_and_keys_to_check.append((stack, key)) self._stacks_and_keys_to_check.append((stack, key))
self._application.callLater(self._checkStack) self._application.callLater(self._checkStack)
@ -168,18 +188,25 @@ class MachineErrorChecker(QObject):
validator = validator_type(key) validator = validator_type(key)
validation_state = validator(stack) validation_state = validator(stack)
if validation_state in (ValidatorState.Exception, ValidatorState.MaximumError, ValidatorState.MinimumError, ValidatorState.Invalid): if validation_state in (ValidatorState.Exception, ValidatorState.MaximumError, ValidatorState.MinimumError, ValidatorState.Invalid):
# Finish # Since we don't know if any of the settings we didn't check is has an error value, store the list for the
self._setResult(True) # next check.
keys_to_recheck = {setting_key for stack, setting_key in self._stacks_and_keys_to_check}
keys_to_recheck.add(key)
self._setResult(True, keys_to_recheck = keys_to_recheck)
return return
# Schedule the check for the next key # Schedule the check for the next key
self._application.callLater(self._checkStack) self._application.callLater(self._checkStack)
def _setResult(self, result: bool) -> None: def _setResult(self, result: bool, keys_to_recheck = None) -> None:
if result != self._has_errors: if result != self._has_errors:
self._has_errors = result self._has_errors = result
self.hasErrorUpdated.emit() self.hasErrorUpdated.emit()
self._machine_manager.stacksValidationChanged.emit() self._machine_manager.stacksValidationChanged.emit()
if keys_to_recheck is None:
self._keys_to_check = set()
else:
self._keys_to_check = keys_to_recheck
self._need_to_check = False self._need_to_check = False
self._check_in_progress = False self._check_in_progress = False
self.needToWaitForResultChanged.emit() self.needToWaitForResultChanged.emit()

View file

@ -17,10 +17,12 @@ from cura.Machines.VariantNode import VariantNode
import UM.FlameProfiler import UM.FlameProfiler
## This class represents a machine in the container tree.
#
# The subnodes of these nodes are variants.
class MachineNode(ContainerNode): class MachineNode(ContainerNode):
"""This class represents a machine in the container tree.
The subnodes of these nodes are variants.
"""
def __init__(self, container_id: str) -> None: def __init__(self, container_id: str) -> None:
super().__init__(container_id) super().__init__(container_id)
self.variants = {} # type: Dict[str, VariantNode] # Mapping variant names to their nodes. self.variants = {} # type: Dict[str, VariantNode] # Mapping variant names to their nodes.
@ -47,20 +49,21 @@ class MachineNode(ContainerNode):
self._loadAll() self._loadAll()
## Get the available quality groups for this machine.
#
# This returns all quality groups, regardless of whether they are
# available to the combination of extruders or not. On the resulting
# quality groups, the is_available property is set to indicate whether the
# quality group can be selected according to the combination of extruders
# in the parameters.
# \param variant_names The names of the variants loaded in each extruder.
# \param material_bases The base file names of the materials loaded in
# each extruder.
# \param extruder_enabled Whether or not the extruders are enabled. This
# allows the function to set the is_available properly.
# \return For each available quality type, a QualityGroup instance.
def getQualityGroups(self, variant_names: List[str], material_bases: List[str], extruder_enabled: List[bool]) -> Dict[str, QualityGroup]: def getQualityGroups(self, variant_names: List[str], material_bases: List[str], extruder_enabled: List[bool]) -> Dict[str, QualityGroup]:
"""Get the available quality groups for this machine.
This returns all quality groups, regardless of whether they are available to the combination of extruders or
not. On the resulting quality groups, the is_available property is set to indicate whether the quality group
can be selected according to the combination of extruders in the parameters.
:param variant_names: The names of the variants loaded in each extruder.
:param material_bases: The base file names of the materials loaded in each extruder.
:param extruder_enabled: Whether or not the extruders are enabled. This allows the function to set the
is_available properly.
:return: For each available quality type, a QualityGroup instance.
"""
if len(variant_names) != len(material_bases) or len(variant_names) != len(extruder_enabled): if len(variant_names) != len(material_bases) or len(variant_names) != len(extruder_enabled):
Logger.log("e", "The number of extruders in the list of variants (" + str(len(variant_names)) + ") is not equal to the number of extruders in the list of materials (" + str(len(material_bases)) + ") or the list of enabled extruders (" + str(len(extruder_enabled)) + ").") Logger.log("e", "The number of extruders in the list of variants (" + str(len(variant_names)) + ") is not equal to the number of extruders in the list of materials (" + str(len(material_bases)) + ") or the list of enabled extruders (" + str(len(extruder_enabled)) + ").")
return {} return {}
@ -98,28 +101,26 @@ class MachineNode(ContainerNode):
quality_groups[quality_type].is_available = True quality_groups[quality_type].is_available = True
return quality_groups return quality_groups
## Returns all of the quality changes groups available to this printer.
#
# The quality changes groups store which quality type and intent category
# they were made for, but not which material and nozzle. Instead for the
# quality type and intent category, the quality changes will always be
# available but change the quality type and intent category when
# activated.
#
# The quality changes group does depend on the printer: Which quality
# definition is used.
#
# The quality changes groups that are available do depend on the quality
# types that are available, so it must still be known which extruders are
# enabled and which materials and variants are loaded in them. This allows
# setting the correct is_available flag.
# \param variant_names The names of the variants loaded in each extruder.
# \param material_bases The base file names of the materials loaded in
# each extruder.
# \param extruder_enabled For each extruder whether or not they are
# enabled.
# \return List of all quality changes groups for the printer.
def getQualityChangesGroups(self, variant_names: List[str], material_bases: List[str], extruder_enabled: List[bool]) -> List[QualityChangesGroup]: def getQualityChangesGroups(self, variant_names: List[str], material_bases: List[str], extruder_enabled: List[bool]) -> List[QualityChangesGroup]:
"""Returns all of the quality changes groups available to this printer.
The quality changes groups store which quality type and intent category they were made for, but not which
material and nozzle. Instead for the quality type and intent category, the quality changes will always be
available but change the quality type and intent category when activated.
The quality changes group does depend on the printer: Which quality definition is used.
The quality changes groups that are available do depend on the quality types that are available, so it must
still be known which extruders are enabled and which materials and variants are loaded in them. This allows
setting the correct is_available flag.
:param variant_names: The names of the variants loaded in each extruder.
:param material_bases: The base file names of the materials loaded in each extruder.
:param extruder_enabled: For each extruder whether or not they are enabled.
:return: List of all quality changes groups for the printer.
"""
machine_quality_changes = ContainerRegistry.getInstance().findContainersMetadata(type = "quality_changes", definition = self.quality_definition) # All quality changes for each extruder. machine_quality_changes = ContainerRegistry.getInstance().findContainersMetadata(type = "quality_changes", definition = self.quality_definition) # All quality changes for each extruder.
groups_by_name = {} #type: Dict[str, QualityChangesGroup] # Group quality changes profiles by their display name. The display name must be unique for quality changes. This finds profiles that belong together in a group. groups_by_name = {} #type: Dict[str, QualityChangesGroup] # Group quality changes profiles by their display name. The display name must be unique for quality changes. This finds profiles that belong together in a group.
@ -134,9 +135,7 @@ class MachineNode(ContainerNode):
groups_by_name[name] = QualityChangesGroup(name, quality_type = quality_changes["quality_type"], groups_by_name[name] = QualityChangesGroup(name, quality_type = quality_changes["quality_type"],
intent_category = quality_changes.get("intent_category", "default"), intent_category = quality_changes.get("intent_category", "default"),
parent = CuraApplication.getInstance()) parent = CuraApplication.getInstance())
# CURA-6882
# Custom qualities are always available, even if they are based on the "not supported" profile.
groups_by_name[name].is_available = True
elif groups_by_name[name].intent_category == "default": # Intent category should be stored as "default" if everything is default or as the intent if any of the extruder have an actual intent. elif groups_by_name[name].intent_category == "default": # Intent category should be stored as "default" if everything is default or as the intent if any of the extruder have an actual intent.
groups_by_name[name].intent_category = quality_changes.get("intent_category", "default") groups_by_name[name].intent_category = quality_changes.get("intent_category", "default")
@ -145,20 +144,33 @@ class MachineNode(ContainerNode):
else: # Global profile. else: # Global profile.
groups_by_name[name].metadata_for_global = quality_changes groups_by_name[name].metadata_for_global = quality_changes
quality_groups = self.getQualityGroups(variant_names, material_bases, extruder_enabled)
for quality_changes_group in groups_by_name.values():
if quality_changes_group.quality_type not in quality_groups:
if quality_changes_group.quality_type == "not_supported":
# Quality changes based on an empty profile are always available.
quality_changes_group.is_available = True
else:
quality_changes_group.is_available = False
else:
# Quality changes group is available iff the quality group it depends on is available. Irrespective of whether the intent category is available.
quality_changes_group.is_available = quality_groups[quality_changes_group.quality_type].is_available
return list(groups_by_name.values()) return list(groups_by_name.values())
## Gets the preferred global quality node, going by the preferred quality
# type.
#
# If the preferred global quality is not in there, an arbitrary global
# quality is taken.
# If there are no global qualities, an empty quality is returned.
def preferredGlobalQuality(self) -> "QualityNode": def preferredGlobalQuality(self) -> "QualityNode":
"""Gets the preferred global quality node, going by the preferred quality type.
If the preferred global quality is not in there, an arbitrary global quality is taken. If there are no global
qualities, an empty quality is returned.
"""
return self.global_qualities.get(self.preferred_quality_type, next(iter(self.global_qualities.values()))) return self.global_qualities.get(self.preferred_quality_type, next(iter(self.global_qualities.values())))
## (Re)loads all variants under this printer.
@UM.FlameProfiler.profile @UM.FlameProfiler.profile
def _loadAll(self) -> None: def _loadAll(self) -> None:
"""(Re)loads all variants under this printer."""
container_registry = ContainerRegistry.getInstance() container_registry = ContainerRegistry.getInstance()
if not self.has_variants: if not self.has_variants:
self.variants["empty"] = VariantNode("empty_variant", machine = self) self.variants["empty"] = VariantNode("empty_variant", machine = self)
@ -171,6 +183,10 @@ class MachineNode(ContainerNode):
if variant_name not in self.variants: if variant_name not in self.variants:
self.variants[variant_name] = VariantNode(variant["id"], machine = self) self.variants[variant_name] = VariantNode(variant["id"], machine = self)
self.variants[variant_name].materialsChanged.connect(self.materialsChanged) self.variants[variant_name].materialsChanged.connect(self.materialsChanged)
else:
# Force reloading the materials if the variant already exists or else materals won't be loaded
# when the G-Code flavor changes --> CURA-7354
self.variants[variant_name]._loadAll()
if not self.variants: if not self.variants:
self.variants["empty"] = VariantNode("empty_variant", machine = self) self.variants["empty"] = VariantNode("empty_variant", machine = self)

View file

@ -7,18 +7,21 @@ if TYPE_CHECKING:
from cura.Machines.MaterialNode import MaterialNode from cura.Machines.MaterialNode import MaterialNode
## A MaterialGroup represents a group of material InstanceContainers that are derived from a single material profile.
# The main InstanceContainer which has the ID of the material profile file name is called the "root_material". For
# example: "generic_abs" is the root material (ID) of "generic_abs_ultimaker3" and "generic_abs_ultimaker3_AA_0.4",
# and "generic_abs_ultimaker3" and "generic_abs_ultimaker3_AA_0.4" are derived materials of "generic_abs".
#
# Using "generic_abs" as an example, the MaterialGroup for "generic_abs" will contain the following information:
# - name: "generic_abs", root_material_id
# - root_material_node: MaterialNode of "generic_abs"
# - derived_material_node_list: A list of MaterialNodes that are derived from "generic_abs",
# so "generic_abs_ultimaker3", "generic_abs_ultimaker3_AA_0.4", etc.
#
class MaterialGroup: class MaterialGroup:
"""A MaterialGroup represents a group of material InstanceContainers that are derived from a single material profile.
The main InstanceContainer which has the ID of the material profile file name is called the "root_material". For
example: "generic_abs" is the root material (ID) of "generic_abs_ultimaker3" and "generic_abs_ultimaker3_AA_0.4",
and "generic_abs_ultimaker3" and "generic_abs_ultimaker3_AA_0.4" are derived materials of "generic_abs".
Using "generic_abs" as an example, the MaterialGroup for "generic_abs" will contain the following information:
- name: "generic_abs", root_material_id
- root_material_node: MaterialNode of "generic_abs"
- derived_material_node_list: A list of MaterialNodes that are derived from "generic_abs", so
"generic_abs_ultimaker3", "generic_abs_ultimaker3_AA_0.4", etc.
"""
__slots__ = ("name", "is_read_only", "root_material_node", "derived_material_node_list") __slots__ = ("name", "is_read_only", "root_material_node", "derived_material_node_list")
def __init__(self, name: str, root_material_node: "MaterialNode") -> None: def __init__(self, name: str, root_material_node: "MaterialNode") -> None:

View file

@ -15,10 +15,12 @@ if TYPE_CHECKING:
from cura.Machines.VariantNode import VariantNode from cura.Machines.VariantNode import VariantNode
## Represents a material in the container tree.
#
# Its subcontainers are quality profiles.
class MaterialNode(ContainerNode): class MaterialNode(ContainerNode):
"""Represents a material in the container tree.
Its subcontainers are quality profiles.
"""
def __init__(self, container_id: str, variant: "VariantNode") -> None: def __init__(self, container_id: str, variant: "VariantNode") -> None:
super().__init__(container_id) super().__init__(container_id)
self.variant = variant self.variant = variant
@ -34,16 +36,16 @@ class MaterialNode(ContainerNode):
container_registry.containerRemoved.connect(self._onRemoved) container_registry.containerRemoved.connect(self._onRemoved)
container_registry.containerMetaDataChanged.connect(self._onMetadataChanged) container_registry.containerMetaDataChanged.connect(self._onMetadataChanged)
## Finds the preferred quality for this printer with this material and this
# variant loaded.
#
# If the preferred quality is not available, an arbitrary quality is
# returned. If there is a configuration mistake (like a typo in the
# preferred quality) this returns a random available quality. If there are
# no available qualities, this will return the empty quality node.
# \return The node for the preferred quality, or any arbitrary quality if
# there is no match.
def preferredQuality(self) -> QualityNode: def preferredQuality(self) -> QualityNode:
"""Finds the preferred quality for this printer with this material and this variant loaded.
If the preferred quality is not available, an arbitrary quality is returned. If there is a configuration
mistake (like a typo in the preferred quality) this returns a random available quality. If there are no
available qualities, this will return the empty quality node.
:return: The node for the preferred quality, or any arbitrary quality if there is no match.
"""
for quality_id, quality_node in self.qualities.items(): for quality_id, quality_node in self.qualities.items():
if self.variant.machine.preferred_quality_type == quality_node.quality_type: if self.variant.machine.preferred_quality_type == quality_node.quality_type:
return quality_node return quality_node
@ -86,8 +88,10 @@ class MaterialNode(ContainerNode):
variant = self.variant.variant_name) variant = self.variant.variant_name)
else: else:
qualities_any_material = container_registry.findInstanceContainersMetadata(type = "quality", definition = self.variant.machine.quality_definition) qualities_any_material = container_registry.findInstanceContainersMetadata(type = "quality", definition = self.variant.machine.quality_definition)
for material_metadata in container_registry.findInstanceContainersMetadata(type = "material", material = my_material_type):
qualities.extend((quality for quality in qualities_any_material if quality.get("material") == material_metadata["base_file"])) all_material_base_files = {material_metadata["base_file"] for material_metadata in container_registry.findInstanceContainersMetadata(type = "material", material = my_material_type)}
qualities.extend((quality for quality in qualities_any_material if quality.get("material") in all_material_base_files))
if not qualities: # No quality profiles found. Go by GUID then. if not qualities: # No quality profiles found. Go by GUID then.
my_guid = self.guid my_guid = self.guid
@ -107,10 +111,13 @@ class MaterialNode(ContainerNode):
if not self.qualities: if not self.qualities:
self.qualities["empty_quality"] = QualityNode("empty_quality", parent = self) self.qualities["empty_quality"] = QualityNode("empty_quality", parent = self)
## Triggered when any container is removed, but only handles it when the
# container is removed that this node represents.
# \param container The container that was allegedly removed.
def _onRemoved(self, container: ContainerInterface) -> None: def _onRemoved(self, container: ContainerInterface) -> None:
"""Triggered when any container is removed, but only handles it when the container is removed that this node
represents.
:param container: The container that was allegedly removed.
"""
if container.getId() == self.container_id: if container.getId() == self.container_id:
# Remove myself from my parent. # Remove myself from my parent.
if self.base_file in self.variant.materials: if self.base_file in self.variant.materials:
@ -119,13 +126,15 @@ class MaterialNode(ContainerNode):
self.variant.materials["empty_material"] = MaterialNode("empty_material", variant = self.variant) self.variant.materials["empty_material"] = MaterialNode("empty_material", variant = self.variant)
self.materialChanged.emit(self) self.materialChanged.emit(self)
## Triggered when any metadata changed in any container, but only handles
# it when the metadata of this node is changed.
# \param container The container whose metadata changed.
# \param kwargs Key-word arguments provided when changing the metadata.
# These are ignored. As far as I know they are never provided to this
# call.
def _onMetadataChanged(self, container: ContainerInterface, **kwargs: Any) -> None: def _onMetadataChanged(self, container: ContainerInterface, **kwargs: Any) -> None:
"""Triggered when any metadata changed in any container, but only handles it when the metadata of this node is
changed.
:param container: The container whose metadata changed.
:param kwargs: Key-word arguments provided when changing the metadata. These are ignored. As far as I know they
are never provided to this call.
"""
if container.getId() != self.container_id: if container.getId() != self.container_id:
return return

View file

@ -13,11 +13,13 @@ from cura.Machines.ContainerTree import ContainerTree
from cura.Machines.MaterialNode import MaterialNode from cura.Machines.MaterialNode import MaterialNode
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
## This is the base model class for GenericMaterialsModel and MaterialBrandsModel.
# Those 2 models are used by the material drop down menu to show generic materials and branded materials separately.
# The extruder position defined here is being used to bound a menu to the correct extruder. This is used in the top
# bar menu "Settings" -> "Extruder nr" -> "Material" -> this menu
class BaseMaterialsModel(ListModel): class BaseMaterialsModel(ListModel):
"""This is the base model class for GenericMaterialsModel and MaterialBrandsModel.
Those 2 models are used by the material drop down menu to show generic materials and branded materials
separately. The extruder position defined here is being used to bound a menu to the correct extruder. This is
used in the top bar menu "Settings" -> "Extruder nr" -> "Material" -> this menu
"""
extruderPositionChanged = pyqtSignal() extruderPositionChanged = pyqtSignal()
enabledChanged = pyqtSignal() enabledChanged = pyqtSignal()
@ -121,10 +123,13 @@ class BaseMaterialsModel(ListModel):
def enabled(self): def enabled(self):
return self._enabled return self._enabled
## Triggered when a list of materials changed somewhere in the container
# tree. This change may trigger an _update() call when the materials
# changed for the configuration that this model is looking for.
def _materialsListChanged(self, material: MaterialNode) -> None: def _materialsListChanged(self, material: MaterialNode) -> None:
"""Triggered when a list of materials changed somewhere in the container
tree. This change may trigger an _update() call when the materials changed for the configuration that this
model is looking for.
"""
if self._extruder_stack is None: if self._extruder_stack is None:
return return
if material.variant.container_id != self._extruder_stack.variant.getId(): if material.variant.container_id != self._extruder_stack.variant.getId():
@ -136,23 +141,25 @@ class BaseMaterialsModel(ListModel):
return return
self._onChanged() self._onChanged()
## Triggered when the list of favorite materials is changed.
def _favoritesChanged(self, material_base_file: str) -> None: def _favoritesChanged(self, material_base_file: str) -> None:
"""Triggered when the list of favorite materials is changed."""
if material_base_file in self._available_materials: if material_base_file in self._available_materials:
self._onChanged() self._onChanged()
## This is an abstract method that needs to be implemented by the specific
# models themselves.
def _update(self): def _update(self):
"""This is an abstract method that needs to be implemented by the specific models themselves. """
self._favorite_ids = set(cura.CuraApplication.CuraApplication.getInstance().getPreferences().getValue("cura/favorite_materials").split(";")) self._favorite_ids = set(cura.CuraApplication.CuraApplication.getInstance().getPreferences().getValue("cura/favorite_materials").split(";"))
# Update the available materials (ContainerNode) for the current active machine and extruder setup. # Update the available materials (ContainerNode) for the current active machine and extruder setup.
global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if not global_stack.hasMaterials: if not global_stack or not global_stack.hasMaterials:
return # There are no materials for this machine, so nothing to do. return # There are no materials for this machine, so nothing to do.
extruder_stack = global_stack.extruders.get(str(self._extruder_position)) extruder_list = global_stack.extruderList
if not extruder_stack: if self._extruder_position > len(extruder_list):
return return
extruder_stack = extruder_list[self._extruder_position]
nozzle_name = extruder_stack.variant.getName() nozzle_name = extruder_stack.variant.getName()
machine_node = ContainerTree.getInstance().machines[global_stack.definition.getId()] machine_node = ContainerTree.getInstance().machines[global_stack.definition.getId()]
if nozzle_name not in machine_node.variants: if nozzle_name not in machine_node.variants:
@ -163,23 +170,23 @@ class BaseMaterialsModel(ListModel):
approximate_material_diameter = extruder_stack.getApproximateMaterialDiameter() approximate_material_diameter = extruder_stack.getApproximateMaterialDiameter()
self._available_materials = {key: material for key, material in materials.items() if float(material.getMetaDataEntry("approximate_diameter", -1)) == approximate_material_diameter} self._available_materials = {key: material for key, material in materials.items() if float(material.getMetaDataEntry("approximate_diameter", -1)) == approximate_material_diameter}
## This method is used by all material models in the beginning of the
# _update() method in order to prevent errors. It's the same in all models
# so it's placed here for easy access.
def _canUpdate(self): def _canUpdate(self):
"""This method is used by all material models in the beginning of the _update() method in order to prevent
errors. It's the same in all models so it's placed here for easy access. """
global_stack = self._machine_manager.activeMachine global_stack = self._machine_manager.activeMachine
if global_stack is None or not self._enabled: if global_stack is None or not self._enabled:
return False return False
extruder_position = str(self._extruder_position) if self._extruder_position >= len(global_stack.extruderList):
if extruder_position not in global_stack.extruders:
return False return False
return True return True
## This is another convenience function which is shared by all material
# models so it's put here to avoid having so much duplicated code.
def _createMaterialItem(self, root_material_id, container_node): def _createMaterialItem(self, root_material_id, container_node):
"""This is another convenience function which is shared by all material models so it's put here to avoid having
so much duplicated code. """
metadata_list = CuraContainerRegistry.getInstance().findContainersMetadata(id = container_node.container_id) metadata_list = CuraContainerRegistry.getInstance().findContainersMetadata(id = container_node.container_id)
if not metadata_list: if not metadata_list:
return None return None

View file

@ -14,9 +14,8 @@ if TYPE_CHECKING:
from UM.Settings.Interfaces import ContainerInterface from UM.Settings.Interfaces import ContainerInterface
## This model is used for the custom profile items in the profile drop down
# menu.
class CustomQualityProfilesDropDownMenuModel(QualityProfilesDropDownMenuModel): class CustomQualityProfilesDropDownMenuModel(QualityProfilesDropDownMenuModel):
"""This model is used for the custom profile items in the profile drop down menu."""
def __init__(self, parent: Optional["QObject"] = None) -> None: def __init__(self, parent: Optional["QObject"] = None) -> None:
super().__init__(parent) super().__init__(parent)

View file

@ -0,0 +1,77 @@
from typing import Optional, TYPE_CHECKING, List, Dict
from PyQt5.QtCore import QObject, pyqtSlot, Qt, pyqtSignal, pyqtProperty
from UM.Qt.ListModel import ListModel
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
class DiscoveredCloudPrintersModel(ListModel):
"""Model used to inform the application about newly added cloud printers, which are discovered from the user's
account """
DeviceKeyRole = Qt.UserRole + 1
DeviceNameRole = Qt.UserRole + 2
DeviceTypeRole = Qt.UserRole + 3
DeviceFirmwareVersionRole = Qt.UserRole + 4
cloudPrintersDetectedChanged = pyqtSignal(bool)
def __init__(self, application: "CuraApplication", parent: Optional["QObject"] = None) -> None:
super().__init__(parent)
self.addRoleName(self.DeviceKeyRole, "key")
self.addRoleName(self.DeviceNameRole, "name")
self.addRoleName(self.DeviceTypeRole, "machine_type")
self.addRoleName(self.DeviceFirmwareVersionRole, "firmware_version")
self._discovered_cloud_printers_list = [] # type: List[Dict[str, str]]
self._application = application # type: CuraApplication
def addDiscoveredCloudPrinters(self, new_devices: List[Dict[str, str]]) -> None:
"""Adds all the newly discovered cloud printers into the DiscoveredCloudPrintersModel.
Example new_devices entry:
.. code-block:: python
{
"key": "YjW8pwGYcaUvaa0YgVyWeFkX3z",
"name": "NG 001",
"machine_type": "Ultimaker S5",
"firmware_version": "5.5.12.202001"
}
:param new_devices: List of dictionaries which contain information about added cloud printers.
:return: None
"""
self._discovered_cloud_printers_list.extend(new_devices)
self._update()
# Inform whether new cloud printers have been detected. If they have, the welcome wizard can close.
self.cloudPrintersDetectedChanged.emit(len(new_devices) > 0)
@pyqtSlot()
def clear(self) -> None:
"""Clears the contents of the DiscoveredCloudPrintersModel.
:return: None
"""
self._discovered_cloud_printers_list = []
self._update()
self.cloudPrintersDetectedChanged.emit(False)
def _update(self) -> None:
"""Sorts the newly discovered cloud printers by name and then updates the ListModel.
:return: None
"""
items = self._discovered_cloud_printers_list[:]
items.sort(key = lambda k: k["name"])
self.setItems(items)

View file

@ -72,8 +72,6 @@ class DiscoveredPrinter(QObject):
# Human readable machine type string # Human readable machine type string
@pyqtProperty(str, notify = machineTypeChanged) @pyqtProperty(str, notify = machineTypeChanged)
def readableMachineType(self) -> str: def readableMachineType(self) -> str:
from cura.CuraApplication import CuraApplication
machine_manager = CuraApplication.getInstance().getMachineManager()
# In NetworkOutputDevice, when it updates a printer information, it updates the machine type using the field # In NetworkOutputDevice, when it updates a printer information, it updates the machine type using the field
# "machine_variant", and for some reason, it's not the machine type ID/codename/... but a human-readable string # "machine_variant", and for some reason, it's not the machine type ID/codename/... but a human-readable string
# like "Ultimaker 3". The code below handles this case. # like "Ultimaker 3". The code below handles this case.
@ -117,12 +115,11 @@ class DiscoveredPrinter(QObject):
return catalog.i18nc("@label", "Available networked printers") return catalog.i18nc("@label", "Available networked printers")
#
# Discovered printers are all the printers that were found on the network, which provide a more convenient way
# to add networked printers (Plugin finds a bunch of printers, user can select one from the list, plugin can then
# add that printer to Cura as the active one).
#
class DiscoveredPrintersModel(QObject): class DiscoveredPrintersModel(QObject):
"""Discovered printers are all the printers that were found on the network, which provide a more convenient way to
add networked printers (Plugin finds a bunch of printers, user can select one from the list, plugin can then add
that printer to Cura as the active one).
"""
def __init__(self, application: "CuraApplication", parent: Optional["QObject"] = None) -> None: def __init__(self, application: "CuraApplication", parent: Optional["QObject"] = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -131,6 +128,7 @@ class DiscoveredPrintersModel(QObject):
self._discovered_printer_by_ip_dict = dict() # type: Dict[str, DiscoveredPrinter] self._discovered_printer_by_ip_dict = dict() # type: Dict[str, DiscoveredPrinter]
self._plugin_for_manual_device = None # type: Optional[OutputDevicePlugin] self._plugin_for_manual_device = None # type: Optional[OutputDevicePlugin]
self._network_plugin_queue = [] # type: List[OutputDevicePlugin]
self._manual_device_address = "" self._manual_device_address = ""
self._manual_device_request_timeout_in_seconds = 5 # timeout for adding a manual device in seconds self._manual_device_request_timeout_in_seconds = 5 # timeout for adding a manual device in seconds
@ -155,20 +153,25 @@ class DiscoveredPrintersModel(QObject):
all_plugins_dict = self._application.getOutputDeviceManager().getAllOutputDevicePlugins() all_plugins_dict = self._application.getOutputDeviceManager().getAllOutputDevicePlugins()
can_add_manual_plugins = [item for item in filter( self._network_plugin_queue = [item for item in filter(
lambda plugin_item: plugin_item.canAddManualDevice(address) in priority_order, lambda plugin_item: plugin_item.canAddManualDevice(address) in priority_order,
all_plugins_dict.values())] all_plugins_dict.values())]
if not can_add_manual_plugins: if not self._network_plugin_queue:
Logger.log("d", "Could not find a plugin to accept adding %s manually via address.", address) Logger.log("d", "Could not find a plugin to accept adding %s manually via address.", address)
return return
plugin = max(can_add_manual_plugins, key = lambda p: priority_order.index(p.canAddManualDevice(address))) self._attemptToAddManualDevice(address)
self._plugin_for_manual_device = plugin
self._plugin_for_manual_device.addManualDevice(address, callback = self._onManualDeviceRequestFinished) def _attemptToAddManualDevice(self, address: str) -> None:
self._manual_device_address = address if self._network_plugin_queue:
self._manual_device_request_timer.start() self._plugin_for_manual_device = self._network_plugin_queue.pop()
self.hasManualDeviceRequestInProgressChanged.emit() Logger.log("d", "Network plugin %s: attempting to add manual device with address %s.",
self._plugin_for_manual_device.getId(), address)
self._plugin_for_manual_device.addManualDevice(address, callback=self._onManualDeviceRequestFinished)
self._manual_device_address = address
self._manual_device_request_timer.start()
self.hasManualDeviceRequestInProgressChanged.emit()
@pyqtSlot() @pyqtSlot()
def cancelCurrentManualDeviceRequest(self) -> None: def cancelCurrentManualDeviceRequest(self) -> None:
@ -183,8 +186,11 @@ class DiscoveredPrintersModel(QObject):
self.manualDeviceRequestFinished.emit(False) self.manualDeviceRequestFinished.emit(False)
def _onManualRequestTimeout(self) -> None: def _onManualRequestTimeout(self) -> None:
Logger.log("w", "Manual printer [%s] request timed out. Cancel the current request.", self._manual_device_address) address = self._manual_device_address
Logger.log("w", "Manual printer [%s] request timed out. Cancel the current request.", address)
self.cancelCurrentManualDeviceRequest() self.cancelCurrentManualDeviceRequest()
if self._network_plugin_queue:
self._attemptToAddManualDevice(address)
hasManualDeviceRequestInProgressChanged = pyqtSignal() hasManualDeviceRequestInProgressChanged = pyqtSignal()
@ -200,6 +206,8 @@ class DiscoveredPrintersModel(QObject):
self._manual_device_address = "" self._manual_device_address = ""
self.hasManualDeviceRequestInProgressChanged.emit() self.hasManualDeviceRequestInProgressChanged.emit()
self.manualDeviceRequestFinished.emit(success) self.manualDeviceRequestFinished.emit(success)
if not success and self._network_plugin_queue:
self._attemptToAddManualDevice(address)
@pyqtProperty("QVariantMap", notify = discoveredPrintersChanged) @pyqtProperty("QVariantMap", notify = discoveredPrintersChanged)
def discoveredPrintersByAddress(self) -> Dict[str, DiscoveredPrinter]: def discoveredPrintersByAddress(self) -> Dict[str, DiscoveredPrinter]:
@ -256,8 +264,14 @@ class DiscoveredPrintersModel(QObject):
del self._discovered_printer_by_ip_dict[ip_address] del self._discovered_printer_by_ip_dict[ip_address]
self.discoveredPrintersChanged.emit() self.discoveredPrintersChanged.emit()
# A convenience function for QML to create a machine (GlobalStack) out of the given discovered printer.
# This function invokes the given discovered printer's "create_callback" to do this.
@pyqtSlot("QVariant") @pyqtSlot("QVariant")
def createMachineFromDiscoveredPrinter(self, discovered_printer: "DiscoveredPrinter") -> None: def createMachineFromDiscoveredPrinter(self, discovered_printer: "DiscoveredPrinter") -> None:
"""A convenience function for QML to create a machine (GlobalStack) out of the given discovered printer.
This function invokes the given discovered printer's "create_callback" to do this
:param discovered_printer:
"""
discovered_printer.create_callback(discovered_printer.getKey()) discovered_printer.create_callback(discovered_printer.getKey())

View file

@ -15,27 +15,27 @@ if TYPE_CHECKING:
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
## Model that holds extruders.
#
# This model is designed for use by any list of extruders, but specifically
# intended for drop-down lists of the current machine's extruders in place of
# settings.
class ExtrudersModel(ListModel): class ExtrudersModel(ListModel):
"""Model that holds extruders.
This model is designed for use by any list of extruders, but specifically intended for drop-down lists of the
current machine's extruders in place of settings.
"""
# The ID of the container stack for the extruder. # The ID of the container stack for the extruder.
IdRole = Qt.UserRole + 1 IdRole = Qt.UserRole + 1
## Human-readable name of the extruder.
NameRole = Qt.UserRole + 2 NameRole = Qt.UserRole + 2
"""Human-readable name of the extruder."""
## Colour of the material loaded in the extruder.
ColorRole = Qt.UserRole + 3 ColorRole = Qt.UserRole + 3
"""Colour of the material loaded in the extruder."""
## Index of the extruder, which is also the value of the setting itself.
#
# An index of 0 indicates the first extruder, an index of 1 the second
# one, and so on. This is the value that will be saved in instance
# containers.
IndexRole = Qt.UserRole + 4 IndexRole = Qt.UserRole + 4
"""Index of the extruder, which is also the value of the setting itself.
An index of 0 indicates the first extruder, an index of 1 the second one, and so on. This is the value that will
be saved in instance containers. """
# The ID of the definition of the extruder. # The ID of the definition of the extruder.
DefinitionRole = Qt.UserRole + 5 DefinitionRole = Qt.UserRole + 5
@ -50,18 +50,21 @@ class ExtrudersModel(ListModel):
MaterialBrandRole = Qt.UserRole + 9 MaterialBrandRole = Qt.UserRole + 9
ColorNameRole = Qt.UserRole + 10 ColorNameRole = Qt.UserRole + 10
## Is the extruder enabled?
EnabledRole = Qt.UserRole + 11 EnabledRole = Qt.UserRole + 11
"""Is the extruder enabled?"""
MaterialTypeRole = Qt.UserRole + 12
"""The type of the material (e.g. PLA, ABS, PETG, etc.)."""
## List of colours to display if there is no material or the material has no known
# colour.
defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"] defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"]
"""List of colours to display if there is no material or the material has no known colour. """
## Initialises the extruders model, defining the roles and listening for
# changes in the data.
#
# \param parent Parent QtObject of this list.
def __init__(self, parent = None): def __init__(self, parent = None):
"""Initialises the extruders model, defining the roles and listening for changes in the data.
:param parent: Parent QtObject of this list.
"""
super().__init__(parent) super().__init__(parent)
self.addRoleName(self.IdRole, "id") self.addRoleName(self.IdRole, "id")
@ -75,6 +78,7 @@ class ExtrudersModel(ListModel):
self.addRoleName(self.StackRole, "stack") self.addRoleName(self.StackRole, "stack")
self.addRoleName(self.MaterialBrandRole, "material_brand") self.addRoleName(self.MaterialBrandRole, "material_brand")
self.addRoleName(self.ColorNameRole, "color_name") self.addRoleName(self.ColorNameRole, "color_name")
self.addRoleName(self.MaterialTypeRole, "material_type")
self._update_extruder_timer = QTimer() self._update_extruder_timer = QTimer()
self._update_extruder_timer.setInterval(100) self._update_extruder_timer.setInterval(100)
self._update_extruder_timer.setSingleShot(True) self._update_extruder_timer.setSingleShot(True)
@ -101,14 +105,15 @@ class ExtrudersModel(ListModel):
def addOptionalExtruder(self): def addOptionalExtruder(self):
return self._add_optional_extruder return self._add_optional_extruder
## Links to the stack-changed signal of the new extruders when an extruder
# is swapped out or added in the current machine.
#
# \param machine_id The machine for which the extruders changed. This is
# filled by the ExtruderManager.extrudersChanged signal when coming from
# that signal. Application.globalContainerStackChanged doesn't fill this
# signal; it's assumed to be the current printer in that case.
def _extrudersChanged(self, machine_id = None): def _extrudersChanged(self, machine_id = None):
"""Links to the stack-changed signal of the new extruders when an extruder is swapped out or added in the
current machine.
:param machine_id: The machine for which the extruders changed. This is filled by the
ExtruderManager.extrudersChanged signal when coming from that signal. Application.globalContainerStackChanged
doesn't fill this signal; it's assumed to be the current printer in that case.
"""
machine_manager = Application.getInstance().getMachineManager() machine_manager = Application.getInstance().getMachineManager()
if machine_id is not None: if machine_id is not None:
if machine_manager.activeMachine is None: if machine_manager.activeMachine is None:
@ -146,11 +151,13 @@ class ExtrudersModel(ListModel):
def _updateExtruders(self): def _updateExtruders(self):
self._update_extruder_timer.start() self._update_extruder_timer.start()
## Update the list of extruders.
#
# This should be called whenever the list of extruders changes.
@UM.FlameProfiler.profile @UM.FlameProfiler.profile
def __updateExtruders(self): def __updateExtruders(self):
"""Update the list of extruders.
This should be called whenever the list of extruders changes.
"""
extruders_changed = False extruders_changed = False
if self.count != 0: if self.count != 0:
@ -190,7 +197,8 @@ class ExtrudersModel(ListModel):
"variant": extruder.variant.getName() if extruder.variant else "", # e.g. print core "variant": extruder.variant.getName() if extruder.variant else "", # e.g. print core
"stack": extruder, "stack": extruder,
"material_brand": material_brand, "material_brand": material_brand,
"color_name": color_name "color_name": color_name,
"material_type": extruder.material.getMetaDataEntry("material") if extruder.material else "",
} }
items.append(item) items.append(item)
@ -207,7 +215,7 @@ class ExtrudersModel(ListModel):
"id": "", "id": "",
"name": catalog.i18nc("@menuitem", "Not overridden"), "name": catalog.i18nc("@menuitem", "Not overridden"),
"enabled": True, "enabled": True,
"color": "#ffffff", "color": "transparent",
"index": -1, "index": -1,
"definition": "", "definition": "",
"material": "", "material": "",
@ -215,6 +223,7 @@ class ExtrudersModel(ListModel):
"stack": None, "stack": None,
"material_brand": "", "material_brand": "",
"color_name": "", "color_name": "",
"material_type": "",
} }
items.append(item) items.append(item)
if self._items != items: if self._items != items:

View file

@ -4,16 +4,17 @@
from cura.Machines.Models.BaseMaterialsModel import BaseMaterialsModel from cura.Machines.Models.BaseMaterialsModel import BaseMaterialsModel
import cura.CuraApplication # To listen to changes to the preferences. import cura.CuraApplication # To listen to changes to the preferences.
## Model that shows the list of favorite materials.
class FavoriteMaterialsModel(BaseMaterialsModel): class FavoriteMaterialsModel(BaseMaterialsModel):
"""Model that shows the list of favorite materials."""
def __init__(self, parent = None): def __init__(self, parent = None):
super().__init__(parent) super().__init__(parent)
cura.CuraApplication.CuraApplication.getInstance().getPreferences().preferenceChanged.connect(self._onFavoritesChanged) cura.CuraApplication.CuraApplication.getInstance().getPreferences().preferenceChanged.connect(self._onFavoritesChanged)
self._onChanged() self._onChanged()
## Triggered when any preference changes, but only handles it when the list
# of favourites is changed.
def _onFavoritesChanged(self, preference_key: str) -> None: def _onFavoritesChanged(self, preference_key: str) -> None:
"""Triggered when any preference changes, but only handles it when the list of favourites is changed. """
if preference_key != "cura/favorite_materials": if preference_key != "cura/favorite_materials":
return return
self._onChanged() self._onChanged()

View file

@ -11,13 +11,13 @@ if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
#
# This model holds all first-start machine actions for the currently active machine. It has 2 roles:
# - title : the title/name of the action
# - content : the QObject of the QML content of the action
# - action : the MachineAction object itself
#
class FirstStartMachineActionsModel(ListModel): class FirstStartMachineActionsModel(ListModel):
"""This model holds all first-start machine actions for the currently active machine. It has 2 roles:
- title : the title/name of the action
- content : the QObject of the QML content of the action
- action : the MachineAction object itself
"""
TitleRole = Qt.UserRole + 1 TitleRole = Qt.UserRole + 1
ContentRole = Qt.UserRole + 2 ContentRole = Qt.UserRole + 2
@ -73,9 +73,10 @@ class FirstStartMachineActionsModel(ListModel):
self._current_action_index += 1 self._current_action_index += 1
self.currentActionIndexChanged.emit() self.currentActionIndexChanged.emit()
# Resets the current action index to 0 so the wizard panel can show actions from the beginning.
@pyqtSlot() @pyqtSlot()
def reset(self) -> None: def reset(self) -> None:
"""Resets the current action index to 0 so the wizard panel can show actions from the beginning."""
self._current_action_index = 0 self._current_action_index = 0
self.currentActionIndexChanged.emit() self.currentActionIndexChanged.emit()

View file

@ -19,6 +19,7 @@ class GlobalStacksModel(ListModel):
ConnectionTypeRole = Qt.UserRole + 4 ConnectionTypeRole = Qt.UserRole + 4
MetaDataRole = Qt.UserRole + 5 MetaDataRole = Qt.UserRole + 5
DiscoverySourceRole = Qt.UserRole + 6 # For separating local and remote printers in the machine management page DiscoverySourceRole = Qt.UserRole + 6 # For separating local and remote printers in the machine management page
RemovalWarningRole = Qt.UserRole + 7
def __init__(self, parent = None) -> None: def __init__(self, parent = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -42,8 +43,9 @@ class GlobalStacksModel(ListModel):
CuraContainerRegistry.getInstance().containerRemoved.connect(self._onContainerChanged) CuraContainerRegistry.getInstance().containerRemoved.connect(self._onContainerChanged)
self._updateDelayed() self._updateDelayed()
## Handler for container added/removed events from registry
def _onContainerChanged(self, container) -> None: def _onContainerChanged(self, container) -> None:
"""Handler for container added/removed events from registry"""
# We only need to update when the added / removed container GlobalStack # We only need to update when the added / removed container GlobalStack
if isinstance(container, GlobalStack): if isinstance(container, GlobalStack):
self._updateDelayed() self._updateDelayed()
@ -65,13 +67,21 @@ class GlobalStacksModel(ListModel):
if parseBool(container_stack.getMetaDataEntry("hidden", False)): if parseBool(container_stack.getMetaDataEntry("hidden", False)):
continue continue
section_name = "Network enabled printers" if has_remote_connection else "Local printers" device_name = container_stack.getMetaDataEntry("group_name", container_stack.getName())
section_name = "Connected printers" if has_remote_connection else "Preset printers"
section_name = self._catalog.i18nc("@info:title", section_name) section_name = self._catalog.i18nc("@info:title", section_name)
items.append({"name": container_stack.getMetaDataEntry("group_name", container_stack.getName()), default_removal_warning = self._catalog.i18nc(
"@label {0} is the name of a printer that's about to be deleted.",
"Are you sure you wish to remove {0}? This cannot be undone!", device_name
)
removal_warning = container_stack.getMetaDataEntry("removal_warning", default_removal_warning)
items.append({"name": device_name,
"id": container_stack.getId(), "id": container_stack.getId(),
"hasRemoteConnection": has_remote_connection, "hasRemoteConnection": has_remote_connection,
"metadata": container_stack.getMetaData().copy(), "metadata": container_stack.getMetaData().copy(),
"discoverySource": section_name}) "discoverySource": section_name,
items.sort(key = lambda i: (not i["hasRemoteConnection"], i["name"])) "removalWarning": removal_warning})
items.sort(key=lambda i: (not i["hasRemoteConnection"], i["name"]))
self.setItems(items) self.setItems(items)

View file

@ -4,13 +4,12 @@
import collections import collections
from PyQt5.QtCore import Qt, QTimer from PyQt5.QtCore import Qt, QTimer
from typing import TYPE_CHECKING, Optional, Dict from typing import TYPE_CHECKING, Optional, Dict
from cura.Machines.Models.IntentTranslations import intent_translations
from cura.Machines.Models.IntentModel import IntentModel from cura.Machines.Models.IntentModel import IntentModel
from cura.Settings.IntentManager import IntentManager from cura.Settings.IntentManager import IntentManager
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
from UM.Settings.ContainerRegistry import ContainerRegistry #To update the list if anything changes. from UM.Settings.ContainerRegistry import ContainerRegistry #To update the list if anything changes.
from PyQt5.QtCore import pyqtProperty, pyqtSignal from PyQt5.QtCore import pyqtSignal
import cura.CuraApplication import cura.CuraApplication
if TYPE_CHECKING: if TYPE_CHECKING:
from UM.Settings.ContainerRegistry import ContainerInterface from UM.Settings.ContainerRegistry import ContainerInterface
@ -19,9 +18,9 @@ from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
## Lists the intent categories that are available for the current printer
# configuration.
class IntentCategoryModel(ListModel): class IntentCategoryModel(ListModel):
"""Lists the intent categories that are available for the current printer configuration. """
NameRole = Qt.UserRole + 1 NameRole = Qt.UserRole + 1
IntentCategoryRole = Qt.UserRole + 2 IntentCategoryRole = Qt.UserRole + 2
WeightRole = Qt.UserRole + 3 WeightRole = Qt.UserRole + 3
@ -32,10 +31,12 @@ class IntentCategoryModel(ListModel):
_translations = collections.OrderedDict() # type: "collections.OrderedDict[str,Dict[str,Optional[str]]]" _translations = collections.OrderedDict() # type: "collections.OrderedDict[str,Dict[str,Optional[str]]]"
# Translations to user-visible string. Ordered by weight.
# TODO: Create a solution for this name and weight to be used dynamically.
@classmethod @classmethod
def _get_translations(cls): def _get_translations(cls):
"""Translations to user-visible string. Ordered by weight.
TODO: Create a solution for this name and weight to be used dynamically.
"""
if len(cls._translations) == 0: if len(cls._translations) == 0:
cls._translations["default"] = { cls._translations["default"] = {
"name": catalog.i18nc("@label", "Default") "name": catalog.i18nc("@label", "Default")
@ -54,9 +55,12 @@ class IntentCategoryModel(ListModel):
} }
return cls._translations return cls._translations
## Creates a new model for a certain intent category.
# \param The category to list the intent profiles for.
def __init__(self, intent_category: str) -> None: def __init__(self, intent_category: str) -> None:
"""Creates a new model for a certain intent category.
:param intent_category: category to list the intent profiles for.
"""
super().__init__() super().__init__()
self._intent_category = intent_category self._intent_category = intent_category
@ -85,16 +89,18 @@ class IntentCategoryModel(ListModel):
self.update() self.update()
## Updates the list of intents if an intent profile was added or removed.
def _onContainerChange(self, container: "ContainerInterface") -> None: def _onContainerChange(self, container: "ContainerInterface") -> None:
"""Updates the list of intents if an intent profile was added or removed."""
if container.getMetaDataEntry("type") == "intent": if container.getMetaDataEntry("type") == "intent":
self.update() self.update()
def update(self): def update(self):
self._update_timer.start() self._update_timer.start()
## Updates the list of intents.
def _update(self) -> None: def _update(self) -> None:
"""Updates the list of intents."""
available_categories = IntentManager.getInstance().currentAvailableIntentCategories() available_categories = IntentManager.getInstance().currentAvailableIntentCategories()
result = [] result = []
for category in available_categories: for category in available_categories:
@ -110,9 +116,9 @@ class IntentCategoryModel(ListModel):
result.sort(key = lambda k: k["weight"]) result.sort(key = lambda k: k["weight"])
self.setItems(result) self.setItems(result)
## Get a display value for a category.
## for categories and keys
@staticmethod @staticmethod
def translation(category: str, key: str, default: Optional[str] = None): def translation(category: str, key: str, default: Optional[str] = None):
"""Get a display value for a category.for categories and keys"""
display_strings = IntentCategoryModel._get_translations().get(category, {}) display_strings = IntentCategoryModel._get_translations().get(category, {})
return display_strings.get(key, default) return display_strings.get(key, default)

View file

@ -98,8 +98,9 @@ class IntentModel(ListModel):
new_items = sorted(new_items, key = lambda x: x["layer_height"]) new_items = sorted(new_items, key = lambda x: x["layer_height"])
self.setItems(new_items) self.setItems(new_items)
## Get the active materials for all extruders. No duplicates will be returned
def _getActiveMaterials(self) -> Set["MaterialNode"]: def _getActiveMaterials(self) -> Set["MaterialNode"]:
"""Get the active materials for all extruders. No duplicates will be returned"""
global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if global_stack is None: if global_stack is None:
return set() return set()

View file

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

View file

@ -1,10 +1,11 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import copy # To duplicate materials. import copy # To duplicate materials.
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot # To allow the preference page proxy to be used from the actual preferences page. from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl
from typing import Any, Dict, Optional, TYPE_CHECKING from typing import Any, Dict, Optional, TYPE_CHECKING
import uuid # To generate new GUIDs for new materials. import uuid # To generate new GUIDs for new materials.
import zipfile # To export all materials in a .zip archive.
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from UM.Logger import Logger from UM.Logger import Logger
@ -19,28 +20,26 @@ if TYPE_CHECKING:
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
## 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.
class MaterialManagementModel(QObject): class MaterialManagementModel(QObject):
## Triggered when a favorite is added or removed.
# \param The base file of the material is provided as parameter when this
# emits.
favoritesChanged = pyqtSignal(str) favoritesChanged = pyqtSignal(str)
"""Triggered when a favorite is added or removed.
:param The base file of the material is provided as parameter when this emits
"""
## Can a certain material be deleted, or is it still in use in one of the
# container stacks anywhere?
#
# We forbid the user from deleting a material if it's in use in any stack.
# Deleting it while it's in use can lead to corrupted stacks. In the
# future we might enable this functionality again (deleting the material
# from those stacks) but for now it is easier to prevent the user from
# doing this.
# \param material_node The ContainerTree node of the material to check.
# \return Whether or not the material can be removed.
@pyqtSlot("QVariant", result = bool) @pyqtSlot("QVariant", result = bool)
def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool: def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool:
"""Can a certain material be deleted, or is it still in use in one of the container stacks anywhere?
We forbid the user from deleting a material if it's in use in any stack. Deleting it while it's in use can
lead to corrupted stacks. In the future we might enable this functionality again (deleting the material from
those stacks) but for now it is easier to prevent the user from doing this.
:param material_node: The ContainerTree node of the material to check.
:return: Whether or not the material can be removed.
"""
container_registry = CuraContainerRegistry.getInstance() container_registry = CuraContainerRegistry.getInstance()
ids_to_remove = {metadata.get("id", "") for metadata in container_registry.findInstanceContainersMetadata(base_file = material_node.base_file)} ids_to_remove = {metadata.get("id", "") for metadata in container_registry.findInstanceContainersMetadata(base_file = material_node.base_file)}
for extruder_stack in container_registry.findContainerStacks(type = "extruder_train"): for extruder_stack in container_registry.findContainerStacks(type = "extruder_train"):
@ -48,11 +47,14 @@ class MaterialManagementModel(QObject):
return False return False
return True return True
## Change the user-visible name of a material.
# \param material_node The ContainerTree node of the material to rename.
# \param name The new name for the material.
@pyqtSlot("QVariant", str) @pyqtSlot("QVariant", str)
def setMaterialName(self, material_node: "MaterialNode", name: str) -> None: def setMaterialName(self, material_node: "MaterialNode", name: str) -> None:
"""Change the user-visible name of a material.
:param material_node: The ContainerTree node of the material to rename.
:param name: The new name for the material.
"""
container_registry = CuraContainerRegistry.getInstance() container_registry = CuraContainerRegistry.getInstance()
root_material_id = material_node.base_file root_material_id = material_node.base_file
if container_registry.isReadOnly(root_material_id): if container_registry.isReadOnly(root_material_id):
@ -60,18 +62,21 @@ class MaterialManagementModel(QObject):
return return
return container_registry.findContainers(id = root_material_id)[0].setName(name) return container_registry.findContainers(id = root_material_id)[0].setName(name)
## Deletes a material from Cura.
#
# This function does not do any safety checking any more. Please call this
# function only if:
# - The material is not read-only.
# - The material is not used in any stacks.
# If the material was not lazy-loaded yet, this will fully load the
# container. When removing this material node, all other materials with
# the same base fill will also be removed.
# \param material_node The material to remove.
@pyqtSlot("QVariant") @pyqtSlot("QVariant")
def removeMaterial(self, material_node: "MaterialNode") -> None: def removeMaterial(self, material_node: "MaterialNode") -> None:
"""Deletes a material from Cura.
This function does not do any safety checking any more. Please call this function only if:
- The material is not read-only.
- The material is not used in any stacks.
If the material was not lazy-loaded yet, this will fully load the container. When removing this material
node, all other materials with the same base fill will also be removed.
:param material_node: The material to remove.
"""
Logger.info(f"Removing material {material_node.container_id}")
container_registry = CuraContainerRegistry.getInstance() container_registry = CuraContainerRegistry.getInstance()
materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file) materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file)
@ -89,17 +94,19 @@ class MaterialManagementModel(QObject):
for material_metadata in materials_this_base_file: for material_metadata in materials_this_base_file:
container_registry.removeContainer(material_metadata["id"]) container_registry.removeContainer(material_metadata["id"])
## Creates a duplicate of a material with the same GUID and base_file
# metadata.
# \param base_file: The base file of the material to duplicate.
# \param new_base_id A new material ID for the base material. The IDs of
# the submaterials will be based off this one. If not provided, a material
# ID will be generated automatically.
# \param new_metadata Metadata for the new material. If not provided, this
# will be duplicated from the original material.
# \return The root material ID of the duplicate material.
def duplicateMaterialByBaseFile(self, base_file: str, new_base_id: Optional[str] = None, def duplicateMaterialByBaseFile(self, base_file: str, new_base_id: Optional[str] = None,
new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]: new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""Creates a duplicate of a material with the same GUID and base_file metadata
:param base_file: The base file of the material to duplicate.
:param new_base_id: A new material ID for the base material. The IDs of the submaterials will be based off this
one. If not provided, a material ID will be generated automatically.
:param new_metadata: Metadata for the new material. If not provided, this will be duplicated from the original
material.
:return: The root material ID of the duplicate material.
"""
container_registry = CuraContainerRegistry.getInstance() container_registry = CuraContainerRegistry.getInstance()
root_materials = container_registry.findContainers(id = base_file) root_materials = container_registry.findContainers(id = base_file)
@ -171,29 +178,33 @@ class MaterialManagementModel(QObject):
return new_base_id return new_base_id
## Creates a duplicate of a material with the same GUID and base_file
# metadata.
# \param material_node The node representing the material to duplicate.
# \param new_base_id A new material ID for the base material. The IDs of
# the submaterials will be based off this one. If not provided, a material
# ID will be generated automatically.
# \param new_metadata Metadata for the new material. If not provided, this
# will be duplicated from the original material.
# \return The root material ID of the duplicate material.
@pyqtSlot("QVariant", result = str) @pyqtSlot("QVariant", result = str)
def duplicateMaterial(self, material_node: "MaterialNode", new_base_id: Optional[str] = None, def duplicateMaterial(self, material_node: "MaterialNode", new_base_id: Optional[str] = None,
new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]: new_metadata: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""Creates a duplicate of a material with the same GUID and base_file metadata
:param material_node: The node representing the material to duplicate.
:param new_base_id: A new material ID for the base material. The IDs of the submaterials will be based off this
one. If not provided, a material ID will be generated automatically.
:param new_metadata: Metadata for the new material. If not provided, this will be duplicated from the original
material.
:return: The root material ID of the duplicate material.
"""
Logger.info(f"Duplicating material {material_node.base_file} to {new_base_id}")
return self.duplicateMaterialByBaseFile(material_node.base_file, new_base_id, new_metadata) return self.duplicateMaterialByBaseFile(material_node.base_file, new_base_id, new_metadata)
## Create a new material by cloning the preferred material for the current
# material diameter and generate a new GUID.
#
# The material type is explicitly left to be the one from the preferred
# material, since this allows the user to still have SOME profiles to work
# with.
# \return The ID of the newly created material.
@pyqtSlot(result = str) @pyqtSlot(result = str)
def createMaterial(self) -> str: def createMaterial(self) -> str:
"""Create a new material by cloning the preferred material for the current material diameter and generate a new
GUID.
The material type is explicitly left to be the one from the preferred material, since this allows the user to
still have SOME profiles to work with.
:return: The ID of the newly created material.
"""
# Ensure all settings are saved. # Ensure all settings are saved.
application = cura.CuraApplication.CuraApplication.getInstance() application = cura.CuraApplication.CuraApplication.getInstance()
application.saveSettings() application.saveSettings()
@ -218,10 +229,13 @@ class MaterialManagementModel(QObject):
self.duplicateMaterial(preferred_material_node, new_base_id = new_id, new_metadata = new_metadata) self.duplicateMaterial(preferred_material_node, new_base_id = new_id, new_metadata = new_metadata)
return new_id return new_id
## Adds a certain material to the favorite materials.
# \param material_base_file The base file of the material to add.
@pyqtSlot(str) @pyqtSlot(str)
def addFavorite(self, material_base_file: str) -> None: def addFavorite(self, material_base_file: str) -> None:
"""Adds a certain material to the favorite materials.
:param material_base_file: The base file of the material to add.
"""
application = cura.CuraApplication.CuraApplication.getInstance() application = cura.CuraApplication.CuraApplication.getInstance()
favorites = application.getPreferences().getValue("cura/favorite_materials").split(";") favorites = application.getPreferences().getValue("cura/favorite_materials").split(";")
if material_base_file not in favorites: if material_base_file not in favorites:
@ -230,11 +244,13 @@ class MaterialManagementModel(QObject):
application.saveSettings() application.saveSettings()
self.favoritesChanged.emit(material_base_file) self.favoritesChanged.emit(material_base_file)
## Removes a certain material from the favorite materials.
#
# If the material was not in the favorite materials, nothing happens.
@pyqtSlot(str) @pyqtSlot(str)
def removeFavorite(self, material_base_file: str) -> None: def removeFavorite(self, material_base_file: str) -> None:
"""Removes a certain material from the favorite materials.
If the material was not in the favorite materials, nothing happens.
"""
application = cura.CuraApplication.CuraApplication.getInstance() application = cura.CuraApplication.CuraApplication.getInstance()
favorites = application.getPreferences().getValue("cura/favorite_materials").split(";") favorites = application.getPreferences().getValue("cura/favorite_materials").split(";")
try: try:
@ -244,3 +260,40 @@ class MaterialManagementModel(QObject):
self.favoritesChanged.emit(material_base_file) self.favoritesChanged.emit(material_base_file)
except ValueError: # Material was not in the favorites list. except ValueError: # Material was not in the favorites list.
Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file)) Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file))
@pyqtSlot(result = QUrl)
def getPreferredExportAllPath(self) -> QUrl:
"""
Get the preferred path to export materials to.
If there is a removable drive, that should be the preferred path. Otherwise it should be the most recent local
file path.
:return: The preferred path to export all materials to.
"""
cura_application = cura.CuraApplication.CuraApplication.getInstance()
device_manager = cura_application.getOutputDeviceManager()
devices = device_manager.getOutputDevices()
for device in devices:
if device.__class__.__name__ == "RemovableDriveOutputDevice":
return QUrl.fromLocalFile(device.getId())
else: # No removable drives? Use local path.
return cura_application.getDefaultPath("dialog_material_path")
@pyqtSlot(QUrl)
def exportAll(self, file_path: QUrl) -> None:
"""
Export all materials to a certain file path.
:param file_path: The path to export the materials to.
"""
registry = CuraContainerRegistry.getInstance()
archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED)
for metadata in registry.findInstanceContainersMetadata(type = "material"):
if metadata["base_file"] != metadata["id"]: # Only process base files.
continue
if metadata["id"] == "empty_material": # Don't export the empty material.
continue
material = registry.findContainers(id = metadata["id"])[0]
suffix = registry.getMimeTypeForContainer(type(material)).preferredSuffix
filename = metadata["id"] + "." + suffix
archive.writestr(filename, material.serialize())

View file

@ -9,11 +9,11 @@ from UM.Scene.Selection import Selection
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
#
# This is the model for multi build plate feature.
# This has nothing to do with the build plate types you can choose on the sidebar for a machine.
#
class MultiBuildPlateModel(ListModel): class MultiBuildPlateModel(ListModel):
"""This is the model for multi build plate feature.
This has nothing to do with the build plate types you can choose on the sidebar for a machine.
"""
maxBuildPlateChanged = pyqtSignal() maxBuildPlateChanged = pyqtSignal()
activeBuildPlateChanged = pyqtSignal() activeBuildPlateChanged = pyqtSignal()
@ -39,9 +39,10 @@ class MultiBuildPlateModel(ListModel):
self._max_build_plate = max_build_plate self._max_build_plate = max_build_plate
self.maxBuildPlateChanged.emit() self.maxBuildPlateChanged.emit()
## Return the highest build plate number
@pyqtProperty(int, notify = maxBuildPlateChanged) @pyqtProperty(int, notify = maxBuildPlateChanged)
def maxBuildPlate(self): def maxBuildPlate(self):
"""Return the highest build plate number"""
return self._max_build_plate return self._max_build_plate
def setActiveBuildPlate(self, nr): def setActiveBuildPlate(self, nr):

View file

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Any, cast, Dict, Optional, TYPE_CHECKING from typing import Any, cast, Dict, Optional, TYPE_CHECKING
@ -26,10 +26,9 @@ if TYPE_CHECKING:
from cura.Settings.GlobalStack import GlobalStack from cura.Settings.GlobalStack import GlobalStack
#
# This the QML model for the quality management page.
#
class QualityManagementModel(ListModel): class QualityManagementModel(ListModel):
"""This the QML model for the quality management page."""
NameRole = Qt.UserRole + 1 NameRole = Qt.UserRole + 1
IsReadOnlyRole = Qt.UserRole + 2 IsReadOnlyRole = Qt.UserRole + 2
QualityGroupRole = Qt.UserRole + 3 QualityGroupRole = Qt.UserRole + 3
@ -74,11 +73,13 @@ class QualityManagementModel(ListModel):
def _onChange(self) -> None: def _onChange(self) -> None:
self._update_timer.start() self._update_timer.start()
## Deletes a custom profile. It will be gone forever.
# \param quality_changes_group The quality changes group representing the
# profile to delete.
@pyqtSlot(QObject) @pyqtSlot(QObject)
def removeQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup") -> None: def removeQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup") -> None:
"""Deletes a custom profile. It will be gone forever.
:param quality_changes_group: The quality changes group representing the profile to delete.
"""
Logger.log("i", "Removing quality changes group {group_name}".format(group_name = quality_changes_group.name)) Logger.log("i", "Removing quality changes group {group_name}".format(group_name = quality_changes_group.name))
removed_quality_changes_ids = set() removed_quality_changes_ids = set()
container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
@ -95,16 +96,19 @@ class QualityManagementModel(ListModel):
if extruder_stack.qualityChanges.getId() in removed_quality_changes_ids: if extruder_stack.qualityChanges.getId() in removed_quality_changes_ids:
extruder_stack.qualityChanges = empty_quality_changes_container extruder_stack.qualityChanges = empty_quality_changes_container
## Rename a custom profile.
#
# Because the names must be unique, the new name may not actually become
# the name that was given. The actual name is returned by this function.
# \param quality_changes_group The custom profile that must be renamed.
# \param new_name The desired name for the profile.
# \return The actual new name of the profile, after making the name
# unique.
@pyqtSlot(QObject, str, result = str) @pyqtSlot(QObject, str, result = str)
def renameQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup", new_name: str) -> str: def renameQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup", new_name: str) -> str:
"""Rename a custom profile.
Because the names must be unique, the new name may not actually become the name that was given. The actual
name is returned by this function.
:param quality_changes_group: The custom profile that must be renamed.
:param new_name: The desired name for the profile.
:return: The actual new name of the profile, after making the name unique.
"""
Logger.log("i", "Renaming QualityChangesGroup {old_name} to {new_name}.".format(old_name = quality_changes_group.name, new_name = new_name)) Logger.log("i", "Renaming QualityChangesGroup {old_name} to {new_name}.".format(old_name = quality_changes_group.name, new_name = new_name))
if new_name == quality_changes_group.name: if new_name == quality_changes_group.name:
Logger.log("i", "QualityChangesGroup name {name} unchanged.".format(name = quality_changes_group.name)) Logger.log("i", "QualityChangesGroup name {name} unchanged.".format(name = quality_changes_group.name))
@ -128,7 +132,7 @@ class QualityManagementModel(ListModel):
for metadata in quality_changes_group.metadata_per_extruder.values(): for metadata in quality_changes_group.metadata_per_extruder.values():
extruder_container = cast(InstanceContainer, container_registry.findContainers(id = metadata["id"])[0]) extruder_container = cast(InstanceContainer, container_registry.findContainers(id = metadata["id"])[0])
extruder_container.setName(new_name) extruder_container.setName(new_name)
global_container = cast(InstanceContainer, container_registry.findContainers(id=quality_changes_group.metadata_for_global["id"])[0]) global_container = cast(InstanceContainer, container_registry.findContainers(id = quality_changes_group.metadata_for_global["id"])[0])
global_container.setName(new_name) global_container.setName(new_name)
quality_changes_group.name = new_name quality_changes_group.name = new_name
@ -138,13 +142,16 @@ class QualityManagementModel(ListModel):
return new_name return new_name
## Duplicates a given quality profile OR quality changes profile.
# \param new_name The desired name of the new profile. This will be made
# unique, so it might end up with a different name.
# \param quality_model_item The item of this model to duplicate, as
# dictionary. See the descriptions of the roles of this list model.
@pyqtSlot(str, "QVariantMap") @pyqtSlot(str, "QVariantMap")
def duplicateQualityChanges(self, new_name: str, quality_model_item: Dict[str, Any]) -> None: def duplicateQualityChanges(self, new_name: str, quality_model_item: Dict[str, Any]) -> None:
"""Duplicates a given quality profile OR quality changes profile.
:param new_name: The desired name of the new profile. This will be made unique, so it might end up with a
different name.
:param quality_model_item: The item of this model to duplicate, as dictionary. See the descriptions of the
roles of this list model.
"""
global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack() global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if not global_stack: if not global_stack:
Logger.log("i", "No active global stack, cannot duplicate quality (changes) profile.") Logger.log("i", "No active global stack, cannot duplicate quality (changes) profile.")
@ -157,10 +164,16 @@ class QualityManagementModel(ListModel):
quality_group = quality_model_item["quality_group"] quality_group = quality_model_item["quality_group"]
quality_changes_group = quality_model_item["quality_changes_group"] quality_changes_group = quality_model_item["quality_changes_group"]
if quality_changes_group is None: if quality_changes_group is None:
# Create global quality changes only.
new_quality_changes = self._createQualityChanges(quality_group.quality_type, intent_category, new_name, new_quality_changes = self._createQualityChanges(quality_group.quality_type, intent_category, new_name,
global_stack, extruder_stack = None) global_stack, extruder_stack = None)
container_registry.addContainer(new_quality_changes) container_registry.addContainer(new_quality_changes)
for extruder in global_stack.extruderList:
new_extruder_quality_changes = self._createQualityChanges(quality_group.quality_type, intent_category,
new_name,
global_stack, extruder_stack = extruder)
container_registry.addContainer(new_extruder_quality_changes)
else: else:
for metadata in [quality_changes_group.metadata_for_global] + list(quality_changes_group.metadata_per_extruder.values()): for metadata in [quality_changes_group.metadata_for_global] + list(quality_changes_group.metadata_per_extruder.values()):
containers = container_registry.findContainers(id = metadata["id"]) containers = container_registry.findContainers(id = metadata["id"])
@ -170,18 +183,18 @@ class QualityManagementModel(ListModel):
new_id = container_registry.uniqueName(container.getId()) new_id = container_registry.uniqueName(container.getId())
container_registry.addContainer(container.duplicate(new_id, new_name)) container_registry.addContainer(container.duplicate(new_id, new_name))
## Create quality changes containers from the user containers in the active
# stacks.
#
# This will go through the global and extruder stacks and create
# quality_changes containers from the user containers in each stack. These
# then replace the quality_changes containers in the stack and clear the
# user settings.
# \param base_name The new name for the quality changes profile. The final
# name of the profile might be different from this, because it needs to be
# made unique.
@pyqtSlot(str) @pyqtSlot(str)
def createQualityChanges(self, base_name: str) -> None: def createQualityChanges(self, base_name: str) -> None:
"""Create quality changes containers from the user containers in the active stacks.
This will go through the global and extruder stacks and create quality_changes containers from the user
containers in each stack. These then replace the quality_changes containers in the stack and clear the user
settings.
:param base_name: The new name for the quality changes profile. The final name of the profile might be
different from this, because it needs to be made unique.
"""
machine_manager = cura.CuraApplication.CuraApplication.getInstance().getMachineManager() machine_manager = cura.CuraApplication.CuraApplication.getInstance().getMachineManager()
global_stack = machine_manager.activeMachine global_stack = machine_manager.activeMachine
@ -201,7 +214,7 @@ class QualityManagementModel(ListModel):
# Go through the active stacks and create quality_changes containers from the user containers. # Go through the active stacks and create quality_changes containers from the user containers.
container_manager = ContainerManager.getInstance() container_manager = ContainerManager.getInstance()
stack_list = [global_stack] + list(global_stack.extruders.values()) stack_list = [global_stack] + global_stack.extruderList
for stack in stack_list: for stack in stack_list:
quality_container = stack.quality quality_container = stack.quality
quality_changes_container = stack.qualityChanges quality_changes_container = stack.qualityChanges
@ -220,14 +233,16 @@ class QualityManagementModel(ListModel):
container_registry.addContainer(new_changes) container_registry.addContainer(new_changes)
## Create a quality changes container with the given set-up.
# \param quality_type The quality type of the new container.
# \param intent_category The intent category of the new container.
# \param new_name The name of the container. This name must be unique.
# \param machine The global stack to create the profile for.
# \param extruder_stack The extruder stack to create the profile for. If
# not provided, only a global container will be created.
def _createQualityChanges(self, quality_type: str, intent_category: Optional[str], new_name: str, machine: "GlobalStack", extruder_stack: Optional["ExtruderStack"]) -> "InstanceContainer": def _createQualityChanges(self, quality_type: str, intent_category: Optional[str], new_name: str, machine: "GlobalStack", extruder_stack: Optional["ExtruderStack"]) -> "InstanceContainer":
"""Create a quality changes container with the given set-up.
:param quality_type: The quality type of the new container.
:param intent_category: The intent category of the new container.
:param new_name: The name of the container. This name must be unique.
:param machine: The global stack to create the profile for.
:param extruder_stack: The extruder stack to create the profile for. If not provided, only a global container will be created.
"""
container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
base_id = machine.definition.getId() if extruder_stack is None else extruder_stack.getId() base_id = machine.definition.getId() if extruder_stack is None else extruder_stack.getId()
new_id = base_id + "_" + new_name new_id = base_id + "_" + new_name
@ -253,11 +268,13 @@ class QualityManagementModel(ListModel):
quality_changes.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.getInstance().SettingVersion) quality_changes.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.getInstance().SettingVersion)
return quality_changes return quality_changes
## Triggered when any container changed.
#
# This filters the updates to the container manager: When it applies to
# the list of quality changes, we need to update our list.
def _qualityChangesListChanged(self, container: "ContainerInterface") -> None: def _qualityChangesListChanged(self, container: "ContainerInterface") -> None:
"""Triggered when any container changed.
This filters the updates to the container manager: When it applies to the list of quality changes, we need to
update our list.
"""
if container.getMetaDataEntry("type") == "quality_changes": if container.getMetaDataEntry("type") == "quality_changes":
self._update() self._update()
@ -322,6 +339,7 @@ class QualityManagementModel(ListModel):
"layer_height": layer_height, # layer_height is only used for sorting "layer_height": layer_height, # layer_height is only used for sorting
} }
item_list.append(item) item_list.append(item)
# Sort by layer_height for built-in qualities # Sort by layer_height for built-in qualities
item_list = sorted(item_list, key = lambda x: x["layer_height"]) item_list = sorted(item_list, key = lambda x: x["layer_height"])
@ -330,6 +348,9 @@ class QualityManagementModel(ListModel):
available_intent_list = [i for i in available_intent_list if i[0] != "default"] available_intent_list = [i for i in available_intent_list if i[0] != "default"]
result = [] result = []
for intent_category, quality_type in available_intent_list: for intent_category, quality_type in available_intent_list:
if not quality_group_dict[quality_type].is_available:
continue
result.append({ result.append({
"name": quality_group_dict[quality_type].name, # Use the quality name as the display name "name": quality_group_dict[quality_type].name, # Use the quality name as the display name
"is_read_only": True, "is_read_only": True,
@ -350,6 +371,9 @@ class QualityManagementModel(ListModel):
# CURA-6913 Note that custom qualities can be based on "not supported", so the quality group can be None. # CURA-6913 Note that custom qualities can be based on "not supported", so the quality group can be None.
quality_group = quality_group_dict.get(quality_changes_group.quality_type) quality_group = quality_group_dict.get(quality_changes_group.quality_type)
quality_type = quality_changes_group.quality_type quality_type = quality_changes_group.quality_type
if not quality_changes_group.is_available:
continue
item = {"name": quality_changes_group.name, item = {"name": quality_changes_group.name,
"is_read_only": False, "is_read_only": False,
"quality_group": quality_group, "quality_group": quality_group,
@ -366,18 +390,19 @@ class QualityManagementModel(ListModel):
self.setItems(item_list) self.setItems(item_list)
# TODO: Duplicated code here from InstanceContainersModel. Refactor and remove this later.
#
## Gets a list of the possible file filters that the plugins have
# registered they can read or write. The convenience meta-filters
# "All Supported Types" and "All Files" are added when listing
# readers, but not when listing writers.
#
# \param io_type \type{str} name of the needed IO type
# \return A list of strings indicating file name filters for a file
# dialog.
@pyqtSlot(str, result = "QVariantList") @pyqtSlot(str, result = "QVariantList")
def getFileNameFilters(self, io_type): def getFileNameFilters(self, io_type):
"""Gets a list of the possible file filters that the plugins have registered they can read or write.
The convenience meta-filters "All Supported Types" and "All Files" are added when listing readers,
but not when listing writers.
:param io_type: name of the needed IO type
:return: A list of strings indicating file name filters for a file dialog.
TODO: Duplicated code here from InstanceContainersModel. Refactor and remove this later.
"""
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
catalog = i18nCatalog("uranium") catalog = i18nCatalog("uranium")
#TODO: This function should be in UM.Resources! #TODO: This function should be in UM.Resources!
@ -394,9 +419,11 @@ class QualityManagementModel(ListModel):
filters.append(catalog.i18nc("@item:inlistbox", "All Files (*)")) # Also allow arbitrary files, if the user so prefers. filters.append(catalog.i18nc("@item:inlistbox", "All Files (*)")) # Also allow arbitrary files, if the user so prefers.
return filters return filters
## Gets a list of profile reader or writer plugins
# \return List of tuples of (plugin_id, meta_data).
def _getIOPlugins(self, io_type): def _getIOPlugins(self, io_type):
"""Gets a list of profile reader or writer plugins
:return: List of tuples of (plugin_id, meta_data).
"""
from UM.PluginRegistry import PluginRegistry from UM.PluginRegistry import PluginRegistry
pr = PluginRegistry.getInstance() pr = PluginRegistry.getInstance()
active_plugin_ids = pr.getActivePlugins() active_plugin_ids = pr.getActivePlugins()

View file

@ -10,10 +10,9 @@ from cura.Machines.ContainerTree import ContainerTree
from cura.Machines.Models.MachineModelUtils import fetchLayerHeight from cura.Machines.Models.MachineModelUtils import fetchLayerHeight
#
# QML Model for all built-in quality profiles. This model is used for the drop-down quality menu.
#
class QualityProfilesDropDownMenuModel(ListModel): class QualityProfilesDropDownMenuModel(ListModel):
"""QML Model for all built-in quality profiles. This model is used for the drop-down quality menu."""
NameRole = Qt.UserRole + 1 NameRole = Qt.UserRole + 1
QualityTypeRole = Qt.UserRole + 2 QualityTypeRole = Qt.UserRole + 2
LayerHeightRole = Qt.UserRole + 3 LayerHeightRole = Qt.UserRole + 3

View file

@ -1,19 +1,21 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, Qt from PyQt5.QtCore import pyqtProperty, pyqtSignal, Qt
from typing import Set from typing import Set
import cura.CuraApplication import cura.CuraApplication
from UM import i18nCatalog
from UM.Logger import Logger from UM.Logger import Logger
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.ContainerRegistry import ContainerRegistry
import os
#
# This model is used to show details settings of the selected quality in the quality management page.
#
class QualitySettingsModel(ListModel): class QualitySettingsModel(ListModel):
"""This model is used to show details settings of the selected quality in the quality management page."""
KeyRole = Qt.UserRole + 1 KeyRole = Qt.UserRole + 1
LabelRole = Qt.UserRole + 2 LabelRole = Qt.UserRole + 2
UnitRole = Qt.UserRole + 3 UnitRole = Qt.UserRole + 3
@ -82,6 +84,12 @@ class QualitySettingsModel(ListModel):
global_container_stack = self._application.getGlobalContainerStack() global_container_stack = self._application.getGlobalContainerStack()
definition_container = global_container_stack.definition definition_container = global_container_stack.definition
# Try and find a translation catalog for the definition
for file_name in definition_container.getInheritedFiles():
catalog = i18nCatalog(os.path.basename(file_name))
if catalog.hasTranslationLoaded():
self._i18n_catalog = catalog
quality_group = self._selected_quality_item["quality_group"] quality_group = self._selected_quality_item["quality_group"]
quality_changes_group = self._selected_quality_item["quality_changes_group"] quality_changes_group = self._selected_quality_item["quality_changes_group"]
@ -91,7 +99,7 @@ class QualitySettingsModel(ListModel):
if self._selected_position == self.GLOBAL_STACK_POSITION: if self._selected_position == self.GLOBAL_STACK_POSITION:
quality_node = quality_group.node_for_global quality_node = quality_group.node_for_global
else: else:
quality_node = quality_group.nodes_for_extruders.get(str(self._selected_position)) quality_node = quality_group.nodes_for_extruders.get(self._selected_position)
settings_keys = quality_group.getAllKeys() settings_keys = quality_group.getAllKeys()
quality_containers = [] quality_containers = []
if quality_node is not None and quality_node.container is not None: if quality_node is not None and quality_node.container is not None:
@ -101,14 +109,18 @@ class QualitySettingsModel(ListModel):
# the settings in that quality_changes_group. # the settings in that quality_changes_group.
if quality_changes_group is not None: if quality_changes_group is not None:
container_registry = ContainerRegistry.getInstance() container_registry = ContainerRegistry.getInstance()
global_containers = container_registry.findContainers(id = quality_changes_group.metadata_for_global["id"]) metadata_for_global = quality_changes_group.metadata_for_global
global_containers = container_registry.findContainers(id = metadata_for_global["id"])
global_container = None if len(global_containers) == 0 else global_containers[0] global_container = None if len(global_containers) == 0 else global_containers[0]
extruders_containers = {pos: container_registry.findContainers(id = quality_changes_group.metadata_per_extruder[pos]["id"]) for pos in quality_changes_group.metadata_per_extruder} extruders_containers = {pos: container_registry.findContainers(id = quality_changes_group.metadata_per_extruder[pos]["id"]) for pos in quality_changes_group.metadata_per_extruder}
extruders_container = {pos: None if not containers else containers[0] for pos, containers in extruders_containers.items()} extruders_container = {pos: None if not containers else containers[0] for pos, containers in extruders_containers.items()}
quality_changes_metadata = None
if self._selected_position == self.GLOBAL_STACK_POSITION and global_container: if self._selected_position == self.GLOBAL_STACK_POSITION and global_container:
quality_changes_metadata = global_container.getMetaData() quality_changes_metadata = global_container.getMetaData()
else: else:
quality_changes_metadata = extruders_container.get(str(self._selected_position)) extruder = extruders_container.get(self._selected_position)
if extruder:
quality_changes_metadata = extruder.getMetaData()
if quality_changes_metadata is not None: # It can be None if number of extruders are changed during runtime. if quality_changes_metadata is not None: # It can be None if number of extruders are changed during runtime.
container = container_registry.findContainers(id = quality_changes_metadata["id"]) container = container_registry.findContainers(id = quality_changes_metadata["id"])
if container: if container:
@ -152,7 +164,7 @@ class QualitySettingsModel(ListModel):
if self._selected_position == self.GLOBAL_STACK_POSITION: if self._selected_position == self.GLOBAL_STACK_POSITION:
user_value = global_container_stack.userChanges.getProperty(definition.key, "value") user_value = global_container_stack.userChanges.getProperty(definition.key, "value")
else: else:
extruder_stack = global_container_stack.extruders[str(self._selected_position)] extruder_stack = global_container_stack.extruderList[self._selected_position]
user_value = extruder_stack.userChanges.getProperty(definition.key, "value") user_value = extruder_stack.userChanges.getProperty(definition.key, "value")
if profile_value is None and user_value is None: if profile_value is None and user_value is None:

View file

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

View file

@ -1,4 +1,4 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import os import os
@ -7,6 +7,7 @@ from collections import OrderedDict
from PyQt5.QtCore import pyqtSlot, Qt from PyQt5.QtCore import pyqtSlot, Qt
from UM.Application import Application from UM.Application import Application
from UM.Logger import Logger
from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from UM.Settings.SettingFunction import SettingFunction from UM.Settings.SettingFunction import SettingFunction
@ -83,14 +84,18 @@ class UserChangesModel(ListModel):
# Find the category of the instance by moving up until we find a category. # Find the category of the instance by moving up until we find a category.
category = user_changes.getInstance(setting_key).definition category = user_changes.getInstance(setting_key).definition
while category.type != "category": while category is not None and category.type != "category":
category = category.parent category = category.parent
# Handle translation (and fallback if we weren't able to find any translation files. # Handle translation (and fallback if we weren't able to find any translation files.
if self._i18n_catalog: if category is not None:
category_label = self._i18n_catalog.i18nc(category.key + " label", category.label) if self._i18n_catalog:
else: category_label = self._i18n_catalog.i18nc(category.key + " label", category.label)
category_label = category.label else:
category_label = category.label
else: # Setting is not in any category. Shouldn't happen, but it do. See https://sentry.io/share/issue/d735884370154166bc846904d9b812ff/
Logger.error("Setting {key} is not in any setting category.".format(key = setting_key))
category_label = ""
if self._i18n_catalog: if self._i18n_catalog:
label = self._i18n_catalog.i18nc(setting_key + " label", stack.getProperty(setting_key, "label")) label = self._i18n_catalog.i18nc(setting_key + " label", stack.getProperty(setting_key, "label"))

View file

@ -6,12 +6,12 @@ from typing import Any, Dict, Optional
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal
## Data struct to group several quality changes instance containers together.
#
# Each group represents one "custom profile" as the user sees it, which
# contains an instance container for the global stack and one instance
# container per extruder.
class QualityChangesGroup(QObject): class QualityChangesGroup(QObject):
"""Data struct to group several quality changes instance containers together.
Each group represents one "custom profile" as the user sees it, which contains an instance container for the
global stack and one instance container per extruder.
"""
def __init__(self, name: str, quality_type: str, intent_category: str, parent: Optional["QObject"] = None) -> None: def __init__(self, name: str, quality_type: str, intent_category: str, parent: Optional["QObject"] = None) -> None:
super().__init__(parent) super().__init__(parent)

View file

@ -3,36 +3,40 @@
from typing import Dict, Optional, List, Set from typing import Dict, Optional, List, Set
from PyQt5.QtCore import QObject, pyqtSlot
from UM.Logger import Logger from UM.Logger import Logger
from UM.Util import parseBool from UM.Util import parseBool
from cura.Machines.ContainerNode import ContainerNode from cura.Machines.ContainerNode import ContainerNode
## A QualityGroup represents a group of quality containers that must be applied
# to each ContainerStack when it's used.
#
# A concrete example: When there are two extruders and the user selects the
# quality type "normal", this quality type must be applied to all stacks in a
# machine, although each stack can have different containers. So one global
# profile gets put on the global stack and one extruder profile gets put on
# each extruder stack. This quality group then contains the following
# profiles (for instance):
# GlobalStack ExtruderStack 1 ExtruderStack 2
# quality container: um3_global_normal um3_aa04_pla_normal um3_aa04_abs_normal
#
# The purpose of these quality groups is to group the containers that can be
# applied to a configuration, so that when a quality level is selected, the
# container can directly be applied to each stack instead of looking them up
# again.
class QualityGroup: class QualityGroup:
## Constructs a new group. """A QualityGroup represents a group of quality containers that must be applied to each ContainerStack when it's
# \param name The user-visible name for the group. used.
# \param quality_type The quality level that each profile in this group
# has. A concrete example: When there are two extruders and the user selects the quality type "normal", this quality
type must be applied to all stacks in a machine, although each stack can have different containers. So one global
profile gets put on the global stack and one extruder profile gets put on each extruder stack. This quality group
then contains the following profiles (for instance):
- GlobalStack
- ExtruderStack 1
- ExtruderStack 2
quality container:
- um3_global_normal
- um3_aa04_pla_normal
- um3_aa04_abs_normal
The purpose of these quality groups is to group the containers that can be applied to a configuration,
so that when a quality level is selected, the container can directly be applied to each stack instead of looking
them up again.
"""
def __init__(self, name: str, quality_type: str) -> None: def __init__(self, name: str, quality_type: str) -> None:
"""Constructs a new group.
:param name: The user-visible name for the group.
:param quality_type: The quality level that each profile in this group has.
"""
self.name = name self.name = name
self.node_for_global = None # type: Optional[ContainerNode] self.node_for_global = None # type: Optional[ContainerNode]
self.nodes_for_extruders = {} # type: Dict[int, ContainerNode] self.nodes_for_extruders = {} # type: Dict[int, ContainerNode]

View file

@ -13,12 +13,14 @@ if TYPE_CHECKING:
from cura.Machines.MachineNode import MachineNode from cura.Machines.MachineNode import MachineNode
## Represents a quality profile in the container tree.
#
# This may either be a normal quality profile or a global quality profile.
#
# Its subcontainers are intent profiles.
class QualityNode(ContainerNode): class QualityNode(ContainerNode):
"""Represents a quality profile in the container tree.
This may either be a normal quality profile or a global quality profile.
Its subcontainers are intent profiles.
"""
def __init__(self, container_id: str, parent: Union["MaterialNode", "MachineNode"]) -> None: def __init__(self, container_id: str, parent: Union["MaterialNode", "MachineNode"]) -> None:
super().__init__(container_id) super().__init__(container_id)
self.parent = parent self.parent = parent

View file

@ -17,16 +17,16 @@ if TYPE_CHECKING:
from cura.Machines.MachineNode import MachineNode from cura.Machines.MachineNode import MachineNode
## This class represents an extruder variant in the container tree.
#
# The subnodes of these nodes are materials.
#
# This node contains materials with ALL filament diameters underneath it. The
# tree of this variant is not specific to one global stack, so because the
# list of materials can be different per stack depending on the compatible
# material diameter setting, we cannot filter them here. Filtering must be
# done in the model.
class VariantNode(ContainerNode): class VariantNode(ContainerNode):
"""This class represents an extruder variant in the container tree.
The subnodes of these nodes are materials.
This node contains materials with ALL filament diameters underneath it. The tree of this variant is not specific
to one global stack, so because the list of materials can be different per stack depending on the compatible
material diameter setting, we cannot filter them here. Filtering must be done in the model.
"""
def __init__(self, container_id: str, machine: "MachineNode") -> None: def __init__(self, container_id: str, machine: "MachineNode") -> None:
super().__init__(container_id) super().__init__(container_id)
self.machine = machine self.machine = machine
@ -39,9 +39,10 @@ class VariantNode(ContainerNode):
container_registry.containerRemoved.connect(self._materialRemoved) container_registry.containerRemoved.connect(self._materialRemoved)
self._loadAll() self._loadAll()
## (Re)loads all materials under this variant.
@UM.FlameProfiler.profile @UM.FlameProfiler.profile
def _loadAll(self) -> None: def _loadAll(self) -> None:
"""(Re)loads all materials under this variant."""
container_registry = ContainerRegistry.getInstance() container_registry = ContainerRegistry.getInstance()
if not self.machine.has_materials: if not self.machine.has_materials:
@ -69,18 +70,18 @@ class VariantNode(ContainerNode):
if not self.materials: if not self.materials:
self.materials["empty_material"] = MaterialNode("empty_material", variant = self) self.materials["empty_material"] = MaterialNode("empty_material", variant = self)
## Finds the preferred material for this printer with this nozzle in one of
# the extruders.
#
# If the preferred material is not available, an arbitrary material is
# returned. If there is a configuration mistake (like a typo in the
# preferred material) this returns a random available material. If there
# are no available materials, this will return the empty material node.
# \param approximate_diameter The desired approximate diameter of the
# material.
# \return The node for the preferred material, or any arbitrary material
# if there is no match.
def preferredMaterial(self, approximate_diameter: int) -> MaterialNode: def preferredMaterial(self, approximate_diameter: int) -> MaterialNode:
"""Finds the preferred material for this printer with this nozzle in one of the extruders.
If the preferred material is not available, an arbitrary material is returned. If there is a configuration
mistake (like a typo in the preferred material) this returns a random available material. If there are no
available materials, this will return the empty material node.
:param approximate_diameter: The desired approximate diameter of the material.
:return: The node for the preferred material, or any arbitrary material if there is no match.
"""
for base_material, material_node in self.materials.items(): for base_material, material_node in self.materials.items():
if self.machine.preferred_material == base_material and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")): if self.machine.preferred_material == base_material and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")):
return material_node return material_node
@ -107,10 +108,10 @@ class VariantNode(ContainerNode):
)) ))
return fallback return fallback
## When a material gets added to the set of profiles, we need to update our
# tree here.
@UM.FlameProfiler.profile @UM.FlameProfiler.profile
def _materialAdded(self, container: ContainerInterface) -> None: def _materialAdded(self, container: ContainerInterface) -> None:
"""When a material gets added to the set of profiles, we need to update our tree here."""
if container.getMetaDataEntry("type") != "material": if container.getMetaDataEntry("type") != "material":
return # Not interested. return # Not interested.
if not ContainerRegistry.getInstance().findContainersMetadata(id = container.getId()): if not ContainerRegistry.getInstance().findContainersMetadata(id = container.getId()):

View file

@ -4,19 +4,16 @@
import copy import copy
from typing import List from typing import List
from UM.Application import Application
from UM.Job import Job from UM.Job import Job
from UM.Operations.GroupedOperation import GroupedOperation
from UM.Message import Message from UM.Message import Message
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from cura.Arranging.Nest2DArrange import arrange
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")
from cura.Arranging.Arrange import Arrange
from cura.Arranging.ShapeArray import ShapeArray
from UM.Application import Application
from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
class MultiplyObjectsJob(Job): class MultiplyObjectsJob(Job):
def __init__(self, objects, count, min_offset = 8): def __init__(self, objects, count, min_offset = 8):
@ -26,28 +23,27 @@ class MultiplyObjectsJob(Job):
self._min_offset = min_offset self._min_offset = min_offset
def run(self) -> None: def run(self) -> None:
status_message = Message(i18n_catalog.i18nc("@info:status", "Multiplying and placing objects"), lifetime=0, status_message = Message(i18n_catalog.i18nc("@info:status", "Multiplying and placing objects"), lifetime = 0,
dismissable=False, progress=0, title = i18n_catalog.i18nc("@info:title", "Placing Objects")) dismissable = False, progress = 0,
title = i18n_catalog.i18nc("@info:title", "Placing Objects"))
status_message.show() status_message.show()
scene = Application.getInstance().getController().getScene() scene = Application.getInstance().getController().getScene()
total_progress = len(self._objects) * self._count
current_progress = 0
global_container_stack = Application.getInstance().getGlobalContainerStack() global_container_stack = Application.getInstance().getGlobalContainerStack()
if global_container_stack is None: if global_container_stack is None:
return # We can't do anything in this case. return # We can't do anything in this case.
machine_width = global_container_stack.getProperty("machine_width", "value")
machine_depth = global_container_stack.getProperty("machine_depth", "value")
root = scene.getRoot() root = scene.getRoot()
scale = 0.5
arranger = Arrange.create(x = machine_width, y = machine_depth, scene_root = root, scale = scale, min_offset = self._min_offset)
processed_nodes = [] # type: List[SceneNode] processed_nodes = [] # type: List[SceneNode]
nodes = [] nodes = []
not_fit_count = 0 fixed_nodes = []
found_solution_for_all = False for node_ in DepthFirstIterator(root):
# Only count sliceable objects
if node_.callDecoration("isSliceable"):
fixed_nodes.append(node_)
for node in self._objects: for node in self._objects:
# If object is part of a group, multiply group # If object is part of a group, multiply group
current_node = node current_node = node
@ -58,31 +54,8 @@ class MultiplyObjectsJob(Job):
continue continue
processed_nodes.append(current_node) processed_nodes.append(current_node)
node_too_big = False
if node.getBoundingBox().width < machine_width or node.getBoundingBox().depth < machine_depth:
offset_shape_arr, hull_shape_arr = ShapeArray.fromNode(current_node, min_offset = self._min_offset, scale = scale)
else:
node_too_big = True
found_solution_for_all = True
arranger.resetLastPriority()
for _ in range(self._count): for _ in range(self._count):
# We do place the nodes one by one, as we want to yield in between.
new_node = copy.deepcopy(node) new_node = copy.deepcopy(node)
solution_found = False
if not node_too_big:
if offset_shape_arr is not None and hull_shape_arr is not None:
solution_found = arranger.findNodePlacement(new_node, offset_shape_arr, hull_shape_arr)
else:
# The node has no shape, so no need to arrange it. The solution is simple: Do nothing.
solution_found = True
if node_too_big or not solution_found:
found_solution_for_all = False
new_location = new_node.getPosition()
new_location = new_location.set(z = - not_fit_count * 20)
new_node.setPosition(new_location)
not_fit_count += 1
# Same build plate # Same build plate
build_plate_number = current_node.callDecoration("getBuildPlateNumber") build_plate_number = current_node.callDecoration("getBuildPlateNumber")
@ -91,19 +64,16 @@ class MultiplyObjectsJob(Job):
child.callDecoration("setBuildPlateNumber", build_plate_number) child.callDecoration("setBuildPlateNumber", build_plate_number)
nodes.append(new_node) nodes.append(new_node)
current_progress += 1
status_message.setProgress((current_progress / total_progress) * 100)
Job.yieldThread()
Job.yieldThread()
found_solution_for_all = True
if nodes: if nodes:
operation = GroupedOperation() found_solution_for_all = arrange(nodes, Application.getInstance().getBuildVolume(), fixed_nodes,
for new_node in nodes: factor = 10000, add_new_nodes_in_scene = True)
operation.addOperation(AddSceneNodeOperation(new_node, current_node.getParent()))
operation.push()
status_message.hide() status_message.hide()
if not found_solution_for_all: 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")) 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"),
message_type = Message.MessageType.WARNING)
no_full_solution_message.show() no_full_solution_message.show()

View file

@ -1,12 +1,12 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime from datetime import datetime
import json import json
import random import random
from hashlib import sha512 from hashlib import sha512
from base64 import b64encode from base64 import b64encode
from typing import Optional from typing import Optional
import requests import requests
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
@ -16,23 +16,28 @@ from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settin
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S" TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
## Class containing several helpers to deal with the authorization flow.
class AuthorizationHelpers: class AuthorizationHelpers:
"""Class containing several helpers to deal with the authorization flow."""
def __init__(self, settings: "OAuth2Settings") -> None: def __init__(self, settings: "OAuth2Settings") -> None:
self._settings = settings self._settings = settings
self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL) self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL)
@property @property
## The OAuth2 settings object.
def settings(self) -> "OAuth2Settings": def settings(self) -> "OAuth2Settings":
"""The OAuth2 settings object."""
return self._settings return self._settings
## Request the access token from the authorization server.
# \param authorization_code: The authorization code from the 1st step.
# \param verification_code: The verification code needed for the PKCE
# extension.
# \return An AuthenticationResponse object.
def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str) -> "AuthenticationResponse": def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str) -> "AuthenticationResponse":
"""Request the access token from the authorization server.
:param authorization_code: The authorization code from the 1st step.
:param verification_code: The verification code needed for the PKCE extension.
:return: An AuthenticationResponse object.
"""
data = { data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
"redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "",
@ -46,11 +51,14 @@ class AuthorizationHelpers:
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server") return AuthenticationResponse(success=False, err_message="Unable to connect to remote server")
## Request the access token from the authorization server using a refresh token.
# \param refresh_token:
# \return An AuthenticationResponse object.
def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse": def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse":
Logger.log("d", "Refreshing the access token.") """Request the access token from the authorization server using a refresh token.
:param refresh_token:
:return: An AuthenticationResponse object.
"""
Logger.log("d", "Refreshing the access token for [%s]", self._settings.OAUTH_SERVER_URL)
data = { data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
"redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "",
@ -61,13 +69,18 @@ class AuthorizationHelpers:
try: try:
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server") return AuthenticationResponse(success = False, err_message = "Unable to connect to remote server")
except OSError as e:
return AuthenticationResponse(success = False, err_message = "Operating system is unable to set up a secure connection: {err}".format(err = str(e)))
@staticmethod @staticmethod
## Parse the token response from the authorization server into an AuthenticationResponse object.
# \param token_response: The JSON string data response from the authorization server.
# \return An AuthenticationResponse object.
def parseTokenResponse(token_response: requests.models.Response) -> "AuthenticationResponse": def parseTokenResponse(token_response: requests.models.Response) -> "AuthenticationResponse":
"""Parse the token response from the authorization server into an AuthenticationResponse object.
:param token_response: The JSON string data response from the authorization server.
:return: An AuthenticationResponse object.
"""
token_data = None token_data = None
try: try:
@ -89,15 +102,20 @@ class AuthorizationHelpers:
scope=token_data["scope"], scope=token_data["scope"],
received_at=datetime.now().strftime(TOKEN_TIMESTAMP_FORMAT)) received_at=datetime.now().strftime(TOKEN_TIMESTAMP_FORMAT))
## Calls the authentication API endpoint to get the token data.
# \param access_token: The encoded JWT token.
# \return Dict containing some profile data.
def parseJWT(self, access_token: str) -> Optional["UserProfile"]: def parseJWT(self, access_token: str) -> Optional["UserProfile"]:
"""Calls the authentication API endpoint to get the token data.
:param access_token: The encoded JWT token.
:return: Dict containing some profile data.
"""
try: try:
token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = { check_token_url = "{}/check-token".format(self._settings.OAUTH_SERVER_URL)
Logger.log("d", "Checking the access token for [%s]", check_token_url)
token_request = requests.get(check_token_url, headers = {
"Authorization": "Bearer {}".format(access_token) "Authorization": "Bearer {}".format(access_token)
}) })
except requests.exceptions.ConnectionError: except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
# Connection was suddenly dropped. Nothing we can do about that. # Connection was suddenly dropped. Nothing we can do about that.
Logger.logException("w", "Something failed while attempting to parse the JWT token") Logger.logException("w", "Something failed while attempting to parse the JWT token")
return None return None
@ -108,23 +126,32 @@ class AuthorizationHelpers:
if not user_data or not isinstance(user_data, dict): if not user_data or not isinstance(user_data, dict):
Logger.log("w", "Could not parse user data from token: %s", user_data) Logger.log("w", "Could not parse user data from token: %s", user_data)
return None return None
return UserProfile( return UserProfile(
user_id = user_data["user_id"], user_id = user_data["user_id"],
username = user_data["username"], username = user_data["username"],
profile_image_url = user_data.get("profile_image_url", "") profile_image_url = user_data.get("profile_image_url", ""),
organization_id = user_data.get("organization", {}).get("organization_id"),
subscriptions = user_data.get("subscriptions", [])
) )
@staticmethod @staticmethod
## 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 better to
# leave it at 32
def generateVerificationCode(code_length: int = 32) -> str: 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
better to leave it at 32
"""
return "".join(random.choice("0123456789ABCDEF") for i in range(code_length)) return "".join(random.choice("0123456789ABCDEF") for i in range(code_length))
@staticmethod @staticmethod
## Generates a base64 encoded sha512 encrypted version of a given string.
# \param verification_code:
# \return The encrypted code in base64 format.
def generateVerificationCodeChallenge(verification_code: str) -> str: def generateVerificationCodeChallenge(verification_code: str) -> str:
"""Generates a base64 encoded sha512 encrypted version of a given string.
:param verification_code:
:return: The encrypted code in base64 format.
"""
encoded = sha512(verification_code.encode()).digest() encoded = sha512(verification_code.encode()).digest()
return b64encode(encoded, altchars = b"_-").decode() return b64encode(encoded, altchars = b"_-").decode()

View file

@ -14,9 +14,12 @@ if TYPE_CHECKING:
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
## This handler handles all HTTP requests on the local web server.
# It also requests the access token for the 2nd stage of the OAuth flow.
class AuthorizationRequestHandler(BaseHTTPRequestHandler): class AuthorizationRequestHandler(BaseHTTPRequestHandler):
"""This handler handles all HTTP requests on the local web server.
It also requests the access token for the 2nd stage of the OAuth flow.
"""
def __init__(self, request, client_address, server) -> None: def __init__(self, request, client_address, server) -> None:
super().__init__(request, client_address, server) super().__init__(request, client_address, server)
@ -55,10 +58,13 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
# This will cause the server to shut down, so we do it at the very end of the request handling. # This will cause the server to shut down, so we do it at the very end of the request handling.
self.authorization_callback(token_response) self.authorization_callback(token_response)
## Handler for the callback URL redirect.
# \param query Dict containing the HTTP query parameters.
# \return HTTP ResponseData containing a success page to show to the user.
def _handleCallback(self, query: Dict[Any, List]) -> Tuple[ResponseData, Optional[AuthenticationResponse]]: def _handleCallback(self, query: Dict[Any, List]) -> Tuple[ResponseData, Optional[AuthenticationResponse]]:
"""Handler for the callback URL redirect.
:param query: Dict containing the HTTP query parameters.
:return: HTTP ResponseData containing a success page to show to the user.
"""
code = self._queryGet(query, "code") code = self._queryGet(query, "code")
state = self._queryGet(query, "state") state = self._queryGet(query, "state")
if state != self.state: if state != self.state:
@ -95,9 +101,10 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
self.authorization_helpers.settings.AUTH_FAILED_REDIRECT self.authorization_helpers.settings.AUTH_FAILED_REDIRECT
), token_response ), token_response
## Handle all other non-existing server calls.
@staticmethod @staticmethod
def _handleNotFound() -> ResponseData: def _handleNotFound() -> ResponseData:
"""Handle all other non-existing server calls."""
return ResponseData(status = HTTP_STATUS["NOT_FOUND"], content_type = "text/html", data_stream = b"Not found.") return ResponseData(status = HTTP_STATUS["NOT_FOUND"], content_type = "text/html", data_stream = b"Not found.")
def _sendHeaders(self, status: "ResponseStatus", content_type: str, redirect_uri: str = None) -> None: def _sendHeaders(self, status: "ResponseStatus", content_type: str, redirect_uri: str = None) -> None:
@ -110,7 +117,8 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
def _sendData(self, data: bytes) -> None: def _sendData(self, data: bytes) -> None:
self.wfile.write(data) self.wfile.write(data)
## Convenience helper for getting values from a pre-parsed query string
@staticmethod @staticmethod
def _queryGet(query_data: Dict[Any, List], key: str, default: Optional[str] = None) -> Optional[str]: def _queryGet(query_data: Dict[Any, List], key: str, default: Optional[str] = None) -> Optional[str]:
"""Convenience helper for getting values from a pre-parsed query string"""
return query_data.get(key, [default])[0] return query_data.get(key, [default])[0]

View file

@ -2,6 +2,7 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from http.server import HTTPServer from http.server import HTTPServer
from socketserver import ThreadingMixIn
from typing import Callable, Any, TYPE_CHECKING from typing import Callable, Any, TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
@ -9,21 +10,26 @@ if TYPE_CHECKING:
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers
## The authorization request callback handler server. class AuthorizationRequestServer(ThreadingMixIn, HTTPServer):
# This subclass is needed to be able to pass some data to the request handler. """The authorization request callback handler server.
# This cannot be done on the request handler directly as the HTTPServer
# creates an instance of the handler after init. This subclass is needed to be able to pass some data to the request handler. This cannot be done on the request
class AuthorizationRequestServer(HTTPServer): handler directly as the HTTPServer creates an instance of the handler after init.
## Set the authorization helpers instance on the request handler. """
def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None: def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None:
"""Set the authorization helpers instance on the request handler."""
self.RequestHandlerClass.authorization_helpers = authorization_helpers # type: ignore self.RequestHandlerClass.authorization_helpers = authorization_helpers # type: ignore
## Set the authorization callback on the request handler.
def setAuthorizationCallback(self, authorization_callback: Callable[["AuthenticationResponse"], Any]) -> None: def setAuthorizationCallback(self, authorization_callback: Callable[["AuthenticationResponse"], Any]) -> None:
"""Set the authorization callback on the request handler."""
self.RequestHandlerClass.authorization_callback = authorization_callback # type: ignore self.RequestHandlerClass.authorization_callback = authorization_callback # type: ignore
## Set the verification code on the request handler.
def setVerificationCode(self, verification_code: str) -> None: def setVerificationCode(self, verification_code: str) -> None:
"""Set the verification code on the request handler."""
self.RequestHandlerClass.verification_code = verification_code # type: ignore self.RequestHandlerClass.verification_code = verification_code # type: ignore
def setState(self, state: str) -> None: def setState(self, state: str) -> None:

View file

@ -1,35 +1,36 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING, Dict
from urllib.parse import urlencode from urllib.parse import urlencode, quote_plus
import requests.exceptions import requests.exceptions
from PyQt5.QtCore import QUrl from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QDesktopServices from PyQt5.QtGui import QDesktopServices
from UM.Logger import Logger from UM.Logger import Logger
from UM.Message import Message from UM.Message import Message
from UM.Signal import Signal from UM.Signal import Signal
from UM.i18n import i18nCatalog
from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT
from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
from cura.OAuth2.Models import AuthenticationResponse from cura.OAuth2.Models import AuthenticationResponse
from UM.i18n import i18nCatalog
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")
if TYPE_CHECKING: if TYPE_CHECKING:
from cura.OAuth2.Models import UserProfile, OAuth2Settings from cura.OAuth2.Models import UserProfile, OAuth2Settings
from UM.Preferences import Preferences from UM.Preferences import Preferences
MYCLOUD_LOGOFF_URL = "https://mycloud.ultimaker.com/logoff"
## The authorization service is responsible for handling the login flow,
# storing user credentials and providing account information.
class AuthorizationService: class AuthorizationService:
"""The authorization service is responsible for handling the login flow, storing user credentials and providing
account information.
"""
# Emit signal when authentication is completed. # Emit signal when authentication is completed.
onAuthStateChanged = Signal() onAuthStateChanged = Signal()
@ -61,11 +62,16 @@ class AuthorizationService:
if self._preferences: if self._preferences:
self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}")
## Get the user profile as obtained from the JWT (JSON Web Token).
# If the JWT is not yet parsed, calling this will take care of that.
# \return UserProfile if a user is logged in, None otherwise.
# \sa _parseJWT
def getUserProfile(self) -> Optional["UserProfile"]: def getUserProfile(self) -> Optional["UserProfile"]:
"""Get the user profile as obtained from the JWT (JSON Web Token).
If the JWT is not yet parsed, calling this will take care of that.
:return: UserProfile if a user is logged in, None otherwise.
See also: :py:method:`cura.OAuth2.AuthorizationService.AuthorizationService._parseJWT`
"""
if not self._user_profile: if not self._user_profile:
# If no user profile was stored locally, we try to get it from JWT. # If no user profile was stored locally, we try to get it from JWT.
try: try:
@ -83,9 +89,12 @@ class AuthorizationService:
return self._user_profile return self._user_profile
## Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there.
# \return UserProfile if it was able to parse, None otherwise.
def _parseJWT(self) -> Optional["UserProfile"]: def _parseJWT(self) -> Optional["UserProfile"]:
"""Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there.
:return: UserProfile if it was able to parse, None otherwise.
"""
if not self._auth_data or self._auth_data.access_token is None: if not self._auth_data or self._auth_data.access_token is None:
# If no auth data exists, we should always log in again. # If no auth data exists, we should always log in again.
Logger.log("d", "There was no auth data or access token") Logger.log("d", "There was no auth data or access token")
@ -104,12 +113,15 @@ class AuthorizationService:
# The token could not be refreshed using the refresh token. We should login again. # The token could not be refreshed using the refresh token. We should login again.
return None return None
# Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been deleted # Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been deleted
# from the server already. # from the server already. Do not store the auth_data if we could not get new auth_data (eg due to a
self._storeAuthData(self._auth_data) # network error), since this would cause an infinite loop trying to get new auth-data
if self._auth_data.success:
self._storeAuthData(self._auth_data)
return self._auth_helpers.parseJWT(self._auth_data.access_token) return self._auth_helpers.parseJWT(self._auth_data.access_token)
## Get the access token as provided by the repsonse data.
def getAccessToken(self) -> Optional[str]: def getAccessToken(self) -> Optional[str]:
"""Get the access token as provided by the repsonse data."""
if self._auth_data is None: if self._auth_data is None:
Logger.log("d", "No auth data to retrieve the access_token from") Logger.log("d", "No auth data to retrieve the access_token from")
return None return None
@ -124,8 +136,9 @@ class AuthorizationService:
return self._auth_data.access_token if self._auth_data else None return self._auth_data.access_token if self._auth_data else None
## Try to refresh the access token. This should be used when it has expired.
def refreshAccessToken(self) -> None: def refreshAccessToken(self) -> None:
"""Try to refresh the access token. This should be used when it has expired."""
if self._auth_data is None or self._auth_data.refresh_token is None: if self._auth_data is None or self._auth_data.refresh_token is None:
Logger.log("w", "Unable to refresh access token, since there is no refresh token.") Logger.log("w", "Unable to refresh access token, since there is no refresh token.")
return return
@ -137,14 +150,16 @@ class AuthorizationService:
Logger.log("w", "Failed to get a new access token from the server.") Logger.log("w", "Failed to get a new access token from the server.")
self.onAuthStateChanged.emit(logged_in = False) self.onAuthStateChanged.emit(logged_in = False)
## Delete the authentication data that we have stored locally (eg; logout)
def deleteAuthData(self) -> None: def deleteAuthData(self) -> None:
"""Delete the authentication data that we have stored locally (eg; logout)"""
if self._auth_data is not None: if self._auth_data is not None:
self._storeAuthData() self._storeAuthData()
self.onAuthStateChanged.emit(logged_in = False) self.onAuthStateChanged.emit(logged_in = False)
## Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login. def startAuthorizationFlow(self, force_browser_logout: bool = False) -> None:
def startAuthorizationFlow(self) -> None: """Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login."""
Logger.log("d", "Starting new OAuth2 flow...") Logger.log("d", "Starting new OAuth2 flow...")
# Create the tokens needed for the code challenge (PKCE) extension for OAuth2. # Create the tokens needed for the code challenge (PKCE) extension for OAuth2.
@ -155,8 +170,8 @@ class AuthorizationService:
state = AuthorizationHelpers.generateVerificationCode() state = AuthorizationHelpers.generateVerificationCode()
# Create the query string needed for the OAuth2 flow. # Create the query dict needed for the OAuth2 flow.
query_string = urlencode({ query_parameters_dict = {
"client_id": self._settings.CLIENT_ID, "client_id": self._settings.CLIENT_ID,
"redirect_uri": self._settings.CALLBACK_URL, "redirect_uri": self._settings.CALLBACK_URL,
"scope": self._settings.CLIENT_SCOPES, "scope": self._settings.CLIENT_SCOPES,
@ -164,16 +179,45 @@ class AuthorizationService:
"state": state, # Forever in our Hearts, RIP "(.Y.)" (2018-2020) "state": state, # Forever in our Hearts, RIP "(.Y.)" (2018-2020)
"code_challenge": challenge_code, "code_challenge": challenge_code,
"code_challenge_method": "S512" "code_challenge_method": "S512"
}) }
# Open the authorization page in a new browser window.
QDesktopServices.openUrl(QUrl("{}?{}".format(self._auth_url, query_string)))
# Start a local web server to receive the callback URL on. # Start a local web server to receive the callback URL on.
self._server.start(verification_code, state) try:
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"),
message_type = Message.MessageType.WARNING).show()
return
auth_url = self._generate_auth_url(query_parameters_dict, force_browser_logout)
# Open the authorization page in a new browser window.
QDesktopServices.openUrl(QUrl(auth_url))
def _generate_auth_url(self, query_parameters_dict: Dict[str, Optional[str]], force_browser_logout: bool) -> str:
"""
Generates the authentications url based on the original auth_url and the query_parameters_dict to be included.
If there is a request to force logging out of mycloud in the browser, the link to logoff from mycloud is
prepended in order to force the browser to logoff from mycloud and then redirect to the authentication url to
login again. This case is used to sync the accounts between Cura and the browser.
:param query_parameters_dict: A dictionary with the query parameters to be url encoded and added to the
authentication link
:param force_browser_logout: If True, Cura will prepend the MYCLOUD_LOGOFF_URL link before the authentication
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))
if force_browser_logout:
# The url after '?next=' should be urlencoded
auth_url = "{}?next={}".format(MYCLOUD_LOGOFF_URL, quote_plus(auth_url))
return auth_url
## Callback method for the authentication flow.
def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None: def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:
"""Callback method for the authentication flow."""
if auth_response.success: if auth_response.success:
self._storeAuthData(auth_response) self._storeAuthData(auth_response)
self.onAuthStateChanged.emit(logged_in = True) self.onAuthStateChanged.emit(logged_in = True)
@ -181,8 +225,9 @@ class AuthorizationService:
self.onAuthenticationError.emit(logged_in = False, error_message = 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. self._server.stop() # Stop the web server at all times.
## Load authentication data from preferences.
def loadAuthDataFromPreferences(self) -> None: def loadAuthDataFromPreferences(self) -> None:
"""Load authentication data from preferences."""
if self._preferences is None: if self._preferences is None:
Logger.log("e", "Unable to load authentication data, since no preference has been set!") Logger.log("e", "Unable to load authentication data, since no preference has been set!")
return return
@ -198,14 +243,18 @@ class AuthorizationService:
if self._unable_to_get_data_message is not None: if self._unable_to_get_data_message is not None:
self._unable_to_get_data_message.hide() 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)
self._unable_to_get_data_message.show() self._unable_to_get_data_message.show()
except ValueError: except (ValueError, TypeError):
Logger.logException("w", "Could not load auth data from preferences") Logger.logException("w", "Could not load auth data from preferences")
## Store authentication data in preferences.
def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None: def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None:
Logger.log("d", "Attempting to store the auth data") """Store authentication data in preferences."""
Logger.log("d", "Attempting to store the auth data for [%s]", self._settings.OAUTH_SERVER_URL)
if self._preferences is None: if self._preferences is None:
Logger.log("e", "Unable to save authentication data, since no preference has been set!") Logger.log("e", "Unable to save authentication data, since no preference has been set!")
return return
@ -213,10 +262,9 @@ class AuthorizationService:
self._auth_data = auth_data self._auth_data = auth_data
if auth_data: if auth_data:
self._user_profile = self.getUserProfile() self._user_profile = self.getUserProfile()
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data))) self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(auth_data.dump()))
else: else:
self._user_profile = None self._user_profile = None
self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY) self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)
self.accessTokenChanged.emit() self.accessTokenChanged.emit()

View file

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

View file

@ -1,6 +1,6 @@
# Copyright (c) 2020 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import sys
import threading import threading
from typing import Any, Callable, Optional, TYPE_CHECKING from typing import Any, Callable, Optional, TYPE_CHECKING
@ -20,18 +20,23 @@ if TYPE_CHECKING:
class LocalAuthorizationServer: class LocalAuthorizationServer:
## The local LocalAuthorizationServer takes care of the oauth2 callbacks.
# Once the flow is completed, this server should be closed down again by
# calling stop()
# \param auth_helpers An instance of the authorization helpers class.
# \param auth_state_changed_callback A callback function to be called when
# the authorization state changes.
# \param daemon Whether the server thread should be run in daemon mode.
# Note: Daemon threads are abruptly stopped at shutdown. Their resources
# (e.g. open files) may never be released.
def __init__(self, auth_helpers: "AuthorizationHelpers", def __init__(self, auth_helpers: "AuthorizationHelpers",
auth_state_changed_callback: Callable[["AuthenticationResponse"], Any], auth_state_changed_callback: Callable[["AuthenticationResponse"], Any],
daemon: bool) -> None: daemon: bool) -> None:
"""The local LocalAuthorizationServer takes care of the oauth2 callbacks.
Once the flow is completed, this server should be closed down again by calling
:py:meth:`cura.OAuth2.LocalAuthorizationServer.LocalAuthorizationServer.stop()`
:param auth_helpers: An instance of the authorization helpers class.
:param auth_state_changed_callback: A callback function to be called when the authorization state changes.
:param daemon: Whether the server thread should be run in daemon mode.
.. note::
Daemon threads are abruptly stopped at shutdown. Their resources (e.g. open files) may never be released.
"""
self._web_server = None # type: Optional[AuthorizationRequestServer] self._web_server = None # type: Optional[AuthorizationRequestServer]
self._web_server_thread = None # type: Optional[threading.Thread] self._web_server_thread = None # type: Optional[threading.Thread]
self._web_server_port = auth_helpers.settings.CALLBACK_PORT self._web_server_port = auth_helpers.settings.CALLBACK_PORT
@ -39,13 +44,17 @@ class LocalAuthorizationServer:
self._auth_state_changed_callback = auth_state_changed_callback self._auth_state_changed_callback = auth_state_changed_callback
self._daemon = daemon self._daemon = daemon
## Starts the local web server to handle the authorization callback.
# \param verification_code The verification code part of the OAuth2 client identification.
# \param state The unique state code (to ensure that the request we get back is really from the server.
def start(self, verification_code: str, state: str) -> None: def start(self, verification_code: str, state: str) -> None:
"""Starts the local web server to handle the authorization callback.
:param verification_code: The verification code part of the OAuth2 client identification.
:param state: The unique state code (to ensure that the request we get back is really from the server.
"""
if self._web_server: if self._web_server:
# If the server is already running (because of a previously aborted auth flow), we don't have to start it. # If the server is already running (because of a previously aborted auth flow), we don't have to start it.
# We still inject the new verification code though. # We still inject the new verification code though.
Logger.log("d", "Auth web server was already running. Updating the verification code")
self._web_server.setVerificationCode(verification_code) self._web_server.setVerificationCode(verification_code)
return return
@ -63,18 +72,39 @@ class LocalAuthorizationServer:
self._web_server.setState(state) self._web_server.setState(state)
# Start the server on a new thread. # Start the server on a new thread.
self._web_server_thread = threading.Thread(None, self._web_server.serve_forever, daemon = self._daemon) self._web_server_thread = threading.Thread(None, self._serve_forever, daemon = self._daemon)
self._web_server_thread.start() self._web_server_thread.start()
## Stops the web server if it was running. It also does some cleanup.
def stop(self) -> None: def stop(self) -> None:
"""Stops the web server if it was running. It also does some cleanup."""
Logger.log("d", "Stopping local oauth2 web server...") Logger.log("d", "Stopping local oauth2 web server...")
if self._web_server: if self._web_server:
try: try:
self._web_server.server_close() self._web_server.shutdown()
except OSError: except OSError:
# OS error can happen if the socket was already closed. We really don't care about that case. # OS error can happen if the socket was already closed. We really don't care about that case.
pass pass
Logger.log("d", "Local oauth2 web server was shut down")
self._web_server = None self._web_server = None
self._web_server_thread = None self._web_server_thread = None
def _serve_forever(self) -> None:
"""
If the platform is windows, this function calls the serve_forever function of the _web_server, catching any
OSErrors that may occur in the thread, thus making the reported message more log-friendly.
If it is any other platform, it just calls the serve_forever function immediately.
: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:
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()

View file

@ -1,6 +1,8 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, List, Union
from copy import deepcopy
from cura.OAuth2.KeyringAttribute import KeyringAttribute
class BaseModel: class BaseModel:
@ -8,8 +10,9 @@ class BaseModel:
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
## OAuth OAuth2Settings data template.
class OAuth2Settings(BaseModel): class OAuth2Settings(BaseModel):
"""OAuth OAuth2Settings data template."""
CALLBACK_PORT = None # type: Optional[int] CALLBACK_PORT = None # type: Optional[int]
OAUTH_SERVER_URL = None # type: Optional[str] OAUTH_SERVER_URL = None # type: Optional[str]
CLIENT_ID = None # type: Optional[str] CLIENT_ID = None # type: Optional[str]
@ -20,42 +23,66 @@ class OAuth2Settings(BaseModel):
AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str
## User profile data template.
class UserProfile(BaseModel): class UserProfile(BaseModel):
"""User profile data template."""
user_id = None # type: Optional[str] user_id = None # type: Optional[str]
username = None # type: Optional[str] username = None # type: Optional[str]
profile_image_url = None # type: Optional[str] profile_image_url = None # type: Optional[str]
organization_id = None # type: Optional[str]
subscriptions = None # type: Optional[List[Dict[str, Any]]]
## Authentication data template.
class AuthenticationResponse(BaseModel): class AuthenticationResponse(BaseModel):
"""Data comes from the token response with success flag and error message added.""" """Authentication data template."""
# Data comes from the token response with success flag and error message added.
success = True # type: bool success = True # type: bool
token_type = None # type: Optional[str] token_type = None # type: Optional[str]
access_token = None # type: Optional[str]
refresh_token = None # type: Optional[str]
expires_in = None # type: Optional[str] expires_in = None # type: Optional[str]
scope = None # type: Optional[str] scope = None # type: Optional[str]
err_message = None # type: Optional[str] err_message = None # type: Optional[str]
received_at = None # type: Optional[str] received_at = None # type: Optional[str]
access_token = KeyringAttribute()
refresh_token = KeyringAttribute()
def __init__(self, **kwargs: Any) -> None:
self.access_token = kwargs.pop("access_token", None)
self.refresh_token = kwargs.pop("refresh_token", None)
super(AuthenticationResponse, self).__init__(**kwargs)
def dump(self) -> Dict[str, Union[bool, Optional[str]]]:
"""
Dumps the dictionary of Authentication attributes. KeyringAttributes are transformed to public attributes
If the keyring was used, these will have a None value, otherwise they will have the secret value
:return: Dictionary of Authentication attributes
"""
dumped = deepcopy(vars(self))
dumped["access_token"] = dumped.pop("_access_token")
dumped["refresh_token"] = dumped.pop("_refresh_token")
return dumped
## Response status template.
class ResponseStatus(BaseModel): class ResponseStatus(BaseModel):
"""Response status template."""
code = 200 # type: int code = 200 # type: int
message = "" # type: str message = "" # type: str
## Response data template.
class ResponseData(BaseModel): class ResponseData(BaseModel):
"""Response data template."""
status = None # type: ResponseStatus status = None # type: ResponseStatus
data_stream = None # type: Optional[bytes] data_stream = None # type: Optional[bytes]
redirect_uri = None # type: Optional[str] redirect_uri = None # type: Optional[str]
content_type = "text/html" # type: str content_type = "text/html" # type: str
## Possible HTTP responses.
HTTP_STATUS = { HTTP_STATUS = {
"""Possible HTTP responses."""
"OK": ResponseStatus(code = 200, message = "OK"), "OK": ResponseStatus(code = 200, message = "OK"),
"NOT_FOUND": ResponseStatus(code = 404, message = "NOT FOUND"), "NOT_FOUND": ResponseStatus(code = 404, message = "NOT FOUND"),
"REDIRECT": ResponseStatus(code = 302, message = "REDIRECT") "REDIRECT": ResponseStatus(code = 302, message = "REDIRECT")

View file

@ -7,27 +7,33 @@ from UM.Scene.Iterator import Iterator
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from functools import cmp_to_key from functools import cmp_to_key
## Iterator that returns a list of nodes in the order that they need to be printed
# If there is no solution an empty list is returned.
# Take note that the list of nodes can have children (that may or may not contain mesh data)
class OneAtATimeIterator(Iterator.Iterator): class OneAtATimeIterator(Iterator.Iterator):
"""Iterator that returns a list of nodes in the order that they need to be printed
If there is no solution an empty list is returned.
Take note that the list of nodes can have children (that may or may not contain mesh data)
"""
def __init__(self, scene_node) -> None: def __init__(self, scene_node) -> None:
super().__init__(scene_node) # Call super to make multiple inheritance work. super().__init__(scene_node) # Call super to make multiple inheritance work.
self._hit_map = [[]] # type: List[List[bool]] # For each node, which other nodes this hits. A grid of booleans on which nodes hit which. self._hit_map = [[]] # type: List[List[bool]] # For each node, which other nodes this hits. A grid of booleans on which nodes hit which.
self._original_node_list = [] # type: List[SceneNode] # The nodes that need to be checked for collisions. self._original_node_list = [] # type: List[SceneNode] # The nodes that need to be checked for collisions.
## Fills the ``_node_stack`` with a list of scene nodes that need to be
# printed in order.
def _fillStack(self) -> None: def _fillStack(self) -> None:
"""Fills the ``_node_stack`` with a list of scene nodes that need to be printed in order. """
node_list = [] node_list = []
for node in self._scene_node.getChildren(): for node in self._scene_node.getChildren():
if not issubclass(type(node), SceneNode): if not issubclass(type(node), SceneNode):
continue continue
# Node can't be printed, so don't bother sending it.
if getattr(node, "_outside_buildarea", False):
continue
if node.callDecoration("getConvexHull"): if node.callDecoration("getConvexHull"):
node_list.append(node) node_list.append(node)
if len(node_list) < 2: if len(node_list) < 2:
self._node_stack = node_list[:] self._node_stack = node_list[:]
return return
@ -35,8 +41,8 @@ class OneAtATimeIterator(Iterator.Iterator):
# Copy the list # Copy the list
self._original_node_list = node_list[:] self._original_node_list = node_list[:]
## Initialise the hit map (pre-compute all hits between all objects) # Initialise the hit map (pre-compute all hits between all objects)
self._hit_map = [[self._checkHit(i,j) for i in node_list] for j in node_list] self._hit_map = [[self._checkHit(i, j) for i in node_list] for j in node_list]
# Check if we have to files that block each other. If this is the case, there is no solution! # Check if we have to files that block each other. If this is the case, there is no solution!
for a in range(0, len(node_list)): for a in range(0, len(node_list)):
@ -75,10 +81,14 @@ class OneAtATimeIterator(Iterator.Iterator):
return True return True
return False return False
## Check for a node whether it hits any of the other nodes.
# \param node The node to check whether it collides with the other nodes.
# \param other_nodes The nodes to check for collisions.
def _checkBlockMultiple(self, node: SceneNode, other_nodes: List[SceneNode]) -> bool: def _checkBlockMultiple(self, node: SceneNode, other_nodes: List[SceneNode]) -> bool:
"""Check for a node whether it hits any of the other nodes.
:param node: The node to check whether it collides with the other nodes.
:param other_nodes: The nodes to check for collisions.
:return: returns collision between nodes
"""
node_index = self._original_node_list.index(node) node_index = self._original_node_list.index(node)
for other_node in other_nodes: for other_node in other_nodes:
other_node_index = self._original_node_list.index(other_node) other_node_index = self._original_node_list.index(other_node)
@ -86,14 +96,26 @@ class OneAtATimeIterator(Iterator.Iterator):
return True return True
return False return False
## Calculate score simply sums the number of other objects it 'blocks'
def _calculateScore(self, a: SceneNode, b: SceneNode) -> int: def _calculateScore(self, a: SceneNode, b: SceneNode) -> int:
"""Calculate score simply sums the number of other objects it 'blocks'
:param a: node
:param b: node
:return: sum of the number of other objects
"""
score_a = sum(self._hit_map[self._original_node_list.index(a)]) score_a = sum(self._hit_map[self._original_node_list.index(a)])
score_b = sum(self._hit_map[self._original_node_list.index(b)]) score_b = sum(self._hit_map[self._original_node_list.index(b)])
return score_a - score_b return score_a - score_b
## Checks if A can be printed before B
def _checkHit(self, a: SceneNode, b: SceneNode) -> bool: def _checkHit(self, a: SceneNode, b: SceneNode) -> bool:
"""Checks if a can be printed before b
:param a: node
:param b: node
:return: true if a can be printed before b
"""
if a == b: if a == b:
return False return False
@ -116,12 +138,14 @@ class OneAtATimeIterator(Iterator.Iterator):
return False return False
## Internal object used to keep track of a possible order in which to print objects.
class _ObjectOrder: class _ObjectOrder:
## Creates the _ObjectOrder instance. """Internal object used to keep track of a possible order in which to print objects."""
# \param order List of indices in which to print objects, ordered by printing
# order.
# \param todo: List of indices which are not yet inserted into the order list.
def __init__(self, order: List[SceneNode], todo: List[SceneNode]) -> None: def __init__(self, order: List[SceneNode], todo: List[SceneNode]) -> None:
"""Creates the _ObjectOrder instance.
:param order: List of indices in which to print objects, ordered by printing order.
:param todo: List of indices which are not yet inserted into the order list.
"""
self.order = order self.order = order
self.todo = todo self.todo = todo

View file

@ -6,8 +6,9 @@ from UM.Operations.GroupedOperation import GroupedOperation
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
## A specialised operation designed specifically to modify the previous operation.
class PlatformPhysicsOperation(Operation): class PlatformPhysicsOperation(Operation):
"""A specialised operation designed specifically to modify the previous operation."""
def __init__(self, node: SceneNode, translation: Vector) -> None: def __init__(self, node: SceneNode, translation: Vector) -> None:
super().__init__() super().__init__()
self._node = node self._node = node

View file

@ -7,8 +7,9 @@ from UM.Operations.Operation import Operation
from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator
## Simple operation to set the buildplate number of a scenenode.
class SetBuildPlateNumberOperation(Operation): class SetBuildPlateNumberOperation(Operation):
"""Simple operation to set the buildplate number of a scenenode."""
def __init__(self, node: SceneNode, build_plate_nr: int) -> None: def __init__(self, node: SceneNode, build_plate_nr: int) -> None:
super().__init__() super().__init__()
self._node = node self._node = node

View file

@ -5,33 +5,38 @@ from typing import Optional
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Operations import Operation from UM.Operations import Operation
from UM.Math.Vector import Vector
## An operation that parents a scene node to another scene node.
class SetParentOperation(Operation.Operation): class SetParentOperation(Operation.Operation):
## Initialises this SetParentOperation. """An operation that parents a scene node to another scene node."""
#
# \param node The node which will be reparented.
# \param parent_node The node which will be the parent.
def __init__(self, node: SceneNode, parent_node: Optional[SceneNode]) -> None: def __init__(self, node: SceneNode, parent_node: Optional[SceneNode]) -> None:
"""Initialises this SetParentOperation.
:param node: The node which will be reparented.
:param parent_node: The node which will be the parent.
"""
super().__init__() super().__init__()
self._node = node self._node = node
self._parent = parent_node self._parent = parent_node
self._old_parent = node.getParent() # To restore the previous parent in case of an undo. self._old_parent = node.getParent() # To restore the previous parent in case of an undo.
## Undoes the set-parent operation, restoring the old parent.
def undo(self) -> None: def undo(self) -> None:
"""Undoes the set-parent operation, restoring the old parent."""
self._set_parent(self._old_parent) self._set_parent(self._old_parent)
## Re-applies the set-parent operation.
def redo(self) -> None: def redo(self) -> None:
"""Re-applies the set-parent operation."""
self._set_parent(self._parent) self._set_parent(self._parent)
## Sets the parent of the node while applying transformations to the world-transform of the node stays the same.
#
# \param new_parent The new parent. Note: this argument can be None, which would hide the node from the scene.
def _set_parent(self, new_parent: Optional[SceneNode]) -> None: def _set_parent(self, new_parent: Optional[SceneNode]) -> None:
"""Sets the parent of the node while applying transformations to the world-transform of the node stays the same.
:param new_parent: The new parent. Note: this argument can be None, which would hide the node from the scene.
"""
if new_parent: if new_parent:
current_parent = self._node.getParent() current_parent = self._node.getParent()
if current_parent: if current_parent:
@ -57,8 +62,10 @@ class SetParentOperation(Operation.Operation):
self._node.setParent(new_parent) self._node.setParent(new_parent)
## Returns a programmer-readable representation of this operation.
#
# \return A programmer-readable representation of this operation.
def __repr__(self) -> str: def __repr__(self) -> str:
"""Returns a programmer-readable representation of this operation.
:return: A programmer-readable representation of this operation.
"""
return "SetParentOperation(node = {0}, parent_node={1})".format(self._node, self._parent) return "SetParentOperation(node = {0}, parent_node={1})".format(self._node, self._parent)

View file

@ -1,14 +1,16 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from UM.Qt.QtApplication import QtApplication from UM.Qt.QtApplication import QtApplication
from UM.Logger import Logger
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
from UM.Resources import Resources from UM.Resources import Resources
from UM.View.RenderPass import RenderPass from UM.View.RenderPass import RenderPass
from UM.View.GL.OpenGL import OpenGL from UM.View.GL.OpenGL import OpenGL
from UM.View.GL.ShaderProgram import InvalidShaderProgramError
from UM.View.RenderBatch import RenderBatch from UM.View.RenderBatch import RenderBatch
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
@ -16,11 +18,15 @@ from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
if TYPE_CHECKING: if TYPE_CHECKING:
from UM.View.GL.ShaderProgram import ShaderProgram from UM.View.GL.ShaderProgram import ShaderProgram
## A RenderPass subclass that renders a the distance of selectable objects from the active camera to a texture.
# The texture is used to map a 2d location (eg the mouse location) to a world space position
#
# Note that in order to increase precision, the 24 bit depth value is encoded into all three of the R,G & B channels
class PickingPass(RenderPass): class PickingPass(RenderPass):
"""A :py:class:`Uranium.UM.View.RenderPass` subclass that renders a the distance of selectable objects from the
active camera to a texture.
The texture is used to map a 2d location (eg the mouse location) to a world space position
.. note:: that in order to increase precision, the 24 bit depth value is encoded into all three of the R,G & B channels
"""
def __init__(self, width: int, height: int) -> None: def __init__(self, width: int, height: int) -> None:
super().__init__("picking", width, height) super().__init__("picking", width, height)
@ -31,7 +37,11 @@ class PickingPass(RenderPass):
def render(self) -> None: def render(self) -> None:
if not self._shader: if not self._shader:
self._shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "camera_distance.shader")) try:
self._shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "camera_distance.shader"))
except InvalidShaderProgramError:
Logger.error("Unable to compile shader program: camera_distance.shader")
return
width, height = self.getSize() width, height = self.getSize()
self._gl.glViewport(0, 0, width, height) self._gl.glViewport(0, 0, width, height)
@ -44,14 +54,20 @@ class PickingPass(RenderPass):
# Fill up the batch with objects that can be sliced. ` # Fill up the batch with objects that can be sliced. `
for node in DepthFirstIterator(self._scene.getRoot()): #type: ignore #Ignore type error because iter() should get called automatically by Python syntax. for node in DepthFirstIterator(self._scene.getRoot()): #type: ignore #Ignore type error because iter() should get called automatically by Python syntax.
if node.callDecoration("isSliceable") and node.getMeshData() and node.isVisible(): if node.callDecoration("isSliceable") and node.getMeshData() and node.isVisible():
batch.addItem(node.getWorldTransformation(), node.getMeshData()) batch.addItem(node.getWorldTransformation(copy = False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix())
self.bind() self.bind()
batch.render(self._scene.getActiveCamera()) batch.render(self._scene.getActiveCamera())
self.release() self.release()
## Get the distance in mm from the camera to at a certain pixel coordinate.
def getPickedDepth(self, x: int, y: int) -> float: def getPickedDepth(self, x: int, y: int) -> float:
"""Get the distance in mm from the camera to at a certain pixel coordinate.
:param x: x component of coordinate vector in pixels
:param y: y component of coordinate vector in pixels
:return: distance in mm from the camera to pixel coordinate
"""
output = self.getOutput() output = self.getOutput()
window_size = self._renderer.getWindowSize() window_size = self._renderer.getWindowSize()
@ -66,8 +82,14 @@ class PickingPass(RenderPass):
distance = (distance & 0x00ffffff) / 1000. # drop the alpha channel and covert to mm distance = (distance & 0x00ffffff) / 1000. # drop the alpha channel and covert to mm
return distance return distance
## Get the world coordinates of a picked point
def getPickedPosition(self, x: int, y: int) -> Vector: def getPickedPosition(self, x: int, y: int) -> Vector:
"""Get the world coordinates of a picked point
:param x: x component of coordinate vector in pixels
:param y: y component of coordinate vector in pixels
:return: vector of the world coordinate
"""
distance = self.getPickedDepth(x, y) distance = self.getPickedDepth(x, y)
camera = self._scene.getActiveCamera() camera = self._scene.getActiveCamera()
if camera: if camera:

View file

@ -1,9 +1,11 @@
# Copyright (c) 2015 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import QTimer from PyQt5.QtCore import QTimer
from shapely.errors import TopologicalError # To capture errors if Shapely messes up.
from UM.Application import Application from UM.Application import Application
from UM.Logger import Logger
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
@ -136,7 +138,11 @@ class PlatformPhysics:
own_convex_hull = node.callDecoration("getConvexHull") own_convex_hull = node.callDecoration("getConvexHull")
other_convex_hull = other_node.callDecoration("getConvexHull") other_convex_hull = other_node.callDecoration("getConvexHull")
if own_convex_hull and other_convex_hull: if own_convex_hull and other_convex_hull:
overlap = own_convex_hull.translate(move_vector.x, move_vector.z).intersectsPolygon(other_convex_hull) try:
overlap = own_convex_hull.translate(move_vector.x, move_vector.z).intersectsPolygon(other_convex_hull)
except TopologicalError as e: # Can happen if the convex hull is degenerate?
Logger.warning("Got a topological error when calculating convex hull intersection: {err}".format(err = str(e)))
overlap = False
if overlap: # Moving ensured that overlap was still there. Try anew! if overlap: # Moving ensured that overlap was still there. Try anew!
temp_move_vector = move_vector.set(x = move_vector.x + overlap[0] * self._move_factor, temp_move_vector = move_vector.set(x = move_vector.x + overlap[0] * self._move_factor,
z = move_vector.z + overlap[1] * self._move_factor) z = move_vector.z + overlap[1] * self._move_factor)
@ -175,7 +181,7 @@ class PlatformPhysics:
if tool.getPluginId() == "TranslateTool": if tool.getPluginId() == "TranslateTool":
for node in Selection.getAllSelectedObjects(): for node in Selection.getAllSelectedObjects():
if node.getBoundingBox().bottom < 0: if node.getBoundingBox() and node.getBoundingBox().bottom < 0:
if not node.getDecorator(ZOffsetDecorator.ZOffsetDecorator): if not node.getDecorator(ZOffsetDecorator.ZOffsetDecorator):
node.addDecorator(ZOffsetDecorator.ZOffsetDecorator()) node.addDecorator(ZOffsetDecorator.ZOffsetDecorator())

View file

@ -1,10 +1,11 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, TYPE_CHECKING, cast from typing import Optional, TYPE_CHECKING, cast, List
from UM.Application import Application from UM.Application import Application
from UM.Logger import Logger
from UM.Resources import Resources from UM.Resources import Resources
from UM.View.RenderPass import RenderPass from UM.View.RenderPass import RenderPass
@ -20,9 +21,14 @@ if TYPE_CHECKING:
from UM.Scene.Camera import Camera from UM.Scene.Camera import Camera
# Make color brighter by normalizing it (maximum factor 2.5 brighter) def prettier_color(color_list: List[float]) -> List[float]:
# color_list is a list of 4 elements: [r, g, b, a], each element is a float 0..1 """Make color brighter by normalizing
def prettier_color(color_list):
maximum factor 2.5 brighter
:param color_list: a list of 4 elements: [r, g, b, a], each element is a float 0..1
:return: a normalized list of 4 elements: [r, g, b, a], each element is a float 0..1
"""
maximum = max(color_list[:3]) maximum = max(color_list[:3])
if maximum > 0: if maximum > 0:
factor = min(1 / maximum, 2.5) factor = min(1 / maximum, 2.5)
@ -31,11 +37,14 @@ def prettier_color(color_list):
return [min(i * factor, 1.0) for i in color_list] return [min(i * factor, 1.0) for i in color_list]
## A render pass subclass that renders slicable objects with default parameters.
# It uses the active camera by default, but it can be overridden to use a different camera.
#
# This is useful to get a preview image of a scene taken from a different location as the active camera.
class PreviewPass(RenderPass): class PreviewPass(RenderPass):
"""A :py:class:`Uranium.UM.View.RenderPass` subclass that renders slicable objects with default parameters.
It uses the active camera by default, but it can be overridden to use a different camera.
This is useful to get a preview image of a scene taken from a different location as the active camera.
"""
def __init__(self, width: int, height: int) -> None: def __init__(self, width: int, height: int) -> None:
super().__init__("preview", width, height, 0) super().__init__("preview", width, height, 0)
@ -61,11 +70,14 @@ class PreviewPass(RenderPass):
self._shader.setUniformValue("u_ambientColor", [0.1, 0.1, 0.1, 1.0]) self._shader.setUniformValue("u_ambientColor", [0.1, 0.1, 0.1, 1.0])
self._shader.setUniformValue("u_specularColor", [0.6, 0.6, 0.6, 1.0]) self._shader.setUniformValue("u_specularColor", [0.6, 0.6, 0.6, 1.0])
self._shader.setUniformValue("u_shininess", 20.0) self._shader.setUniformValue("u_shininess", 20.0)
self._shader.setUniformValue("u_renderError", 0.0) # We don't want any error markers!.
self._shader.setUniformValue("u_faceId", -1) # Don't render any selected faces in the preview. 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")
if not self._non_printing_shader: if not self._non_printing_shader:
self._non_printing_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader"))
if self._non_printing_shader: if self._non_printing_shader:
self._non_printing_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader"))
self._non_printing_shader.setUniformValue("u_diffuseColor", [0.5, 0.5, 0.5, 0.5]) self._non_printing_shader.setUniformValue("u_diffuseColor", [0.5, 0.5, 0.5, 0.5])
self._non_printing_shader.setUniformValue("u_opacity", 0.6) self._non_printing_shader.setUniformValue("u_opacity", 0.6)
@ -102,12 +114,12 @@ class PreviewPass(RenderPass):
1.0] 1.0]
uniforms["diffuse_color"] = prettier_color(diffuse_color) uniforms["diffuse_color"] = prettier_color(diffuse_color)
uniforms["diffuse_color_2"] = diffuse_color2 uniforms["diffuse_color_2"] = diffuse_color2
batch_support_mesh.addItem(node.getWorldTransformation(), node.getMeshData(), uniforms = uniforms) batch_support_mesh.addItem(node.getWorldTransformation(copy = False), node.getMeshData(), uniforms = uniforms)
else: else:
# Normal scene node # Normal scene node
uniforms = {} uniforms = {}
uniforms["diffuse_color"] = prettier_color(cast(CuraSceneNode, node).getDiffuseColor()) uniforms["diffuse_color"] = prettier_color(cast(CuraSceneNode, node).getDiffuseColor())
batch.addItem(node.getWorldTransformation(), node.getMeshData(), uniforms = uniforms) batch.addItem(node.getWorldTransformation(copy = False), node.getMeshData(), uniforms = uniforms)
self.bind() self.bind()

View file

@ -10,8 +10,14 @@ class PrintJobPreviewImageProvider(QQuickImageProvider):
def __init__(self): def __init__(self):
super().__init__(QQuickImageProvider.Image) super().__init__(QQuickImageProvider.Image)
## Request a new image.
def requestImage(self, id: str, size: QSize) -> Tuple[QImage, QSize]: def requestImage(self, id: str, size: QSize) -> Tuple[QImage, QSize]:
"""Request a new image.
:param id: id of the requested image
:param size: is not used defaults to QSize(15, 15)
:return: an tuple containing the image and size
"""
# The id will have an uuid and an increment separated by a slash. As we don't care about the value of the # The id will have an uuid and an increment separated by a slash. As we don't care about the value of the
# increment, we need to strip that first. # increment, we need to strip that first.
uuid = id[id.find("/") + 1:] uuid = id[id.find("/") + 1:]

View file

@ -7,6 +7,8 @@ from enum import IntEnum
from threading import Thread from threading import Thread
from typing import Union from typing import Union
from UM.Logger import Logger
MYPY = False MYPY = False
if MYPY: if MYPY:
from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
@ -33,15 +35,22 @@ class FirmwareUpdater(QObject):
else: else:
self._firmware_file = firmware_file self._firmware_file = firmware_file
self._setFirmwareUpdateState(FirmwareUpdateState.updating) if self._firmware_file == "":
self._setFirmwareUpdateState(FirmwareUpdateState.firmware_not_found_error)
return
self._update_firmware_thread.start() self._setFirmwareUpdateState(FirmwareUpdateState.updating)
try:
self._update_firmware_thread.start()
except RuntimeError:
Logger.warning("Could not start the update thread, since it's still running!")
def _updateFirmware(self) -> None: def _updateFirmware(self) -> None:
raise NotImplementedError("_updateFirmware needs to be implemented") raise NotImplementedError("_updateFirmware needs to be implemented")
## Cleanup after a succesful update
def _cleanupAfterUpdate(self) -> None: def _cleanupAfterUpdate(self) -> None:
"""Cleanup after a succesful update"""
# Clean up for next attempt. # Clean up for next attempt.
self._update_firmware_thread = Thread(target=self._updateFirmware, daemon=True, name = "FirmwareUpdateThread") self._update_firmware_thread = Thread(target=self._updateFirmware, daemon=True, name = "FirmwareUpdateThread")
self._firmware_file = "" self._firmware_file = ""

View file

@ -47,10 +47,13 @@ class ExtruderConfigurationModel(QObject):
def hotendID(self) -> Optional[str]: def hotendID(self) -> Optional[str]:
return self._hotend_id return self._hotend_id
## This method is intended to indicate whether the configuration is valid or not.
# The method checks if the mandatory fields are or not set
# At this moment is always valid since we allow to have empty material and variants.
def isValid(self) -> bool: def isValid(self) -> bool:
"""This method is intended to indicate whether the configuration is valid or not.
The method checks if the mandatory fields are or not set
At this moment is always valid since we allow to have empty material and variants.
"""
return True return True
def __str__(self) -> str: def __str__(self) -> str:
@ -71,11 +74,11 @@ class ExtruderConfigurationModel(QObject):
# Empty materials should be ignored for comparison # Empty materials should be ignored for comparison
if self.activeMaterial is not None and other.activeMaterial is not None: if self.activeMaterial is not None and other.activeMaterial is not None:
if self.activeMaterial.guid != other.activeMaterial.guid: if self.activeMaterial.guid != other.activeMaterial.guid:
if self.activeMaterial.guid != "" and other.activeMaterial.guid != "": if self.activeMaterial.guid == "" and other.activeMaterial.guid == "":
return False
else:
# At this point there is no material, so it doesn't matter what the hotend is. # At this point there is no material, so it doesn't matter what the hotend is.
return True return True
else:
return False
if self.hotendID != other.hotendID: if self.hotendID != other.hotendID:
return False return False

View file

@ -54,8 +54,9 @@ class ExtruderOutputModel(QObject):
def updateActiveMaterial(self, material: Optional["MaterialOutputModel"]) -> None: def updateActiveMaterial(self, material: Optional["MaterialOutputModel"]) -> None:
self._extruder_configuration.setMaterial(material) self._extruder_configuration.setMaterial(material)
## Update the hotend temperature. This only changes it locally.
def updateHotendTemperature(self, temperature: float) -> None: def updateHotendTemperature(self, temperature: float) -> None:
"""Update the hotend temperature. This only changes it locally."""
if self._hotend_temperature != temperature: if self._hotend_temperature != temperature:
self._hotend_temperature = temperature self._hotend_temperature = temperature
self.hotendTemperatureChanged.emit() self.hotendTemperatureChanged.emit()
@ -65,9 +66,10 @@ class ExtruderOutputModel(QObject):
self._target_hotend_temperature = temperature self._target_hotend_temperature = temperature
self.targetHotendTemperatureChanged.emit() self.targetHotendTemperatureChanged.emit()
## Set the target hotend temperature. This ensures that it's actually sent to the remote.
@pyqtSlot(float) @pyqtSlot(float)
def setTargetHotendTemperature(self, temperature: float) -> None: def setTargetHotendTemperature(self, temperature: float) -> None:
"""Set the target hotend temperature. This ensures that it's actually sent to the remote."""
self._printer.getController().setTargetHotendTemperature(self._printer, self, temperature) self._printer.getController().setTargetHotendTemperature(self._printer, self, temperature)
self.updateTargetHotendTemperature(temperature) self.updateTargetHotendTemperature(temperature)
@ -97,17 +99,19 @@ class ExtruderOutputModel(QObject):
self._is_preheating = pre_heating self._is_preheating = pre_heating
self.isPreheatingChanged.emit() self.isPreheatingChanged.emit()
@pyqtProperty(bool, notify=isPreheatingChanged) @pyqtProperty(bool, notify = isPreheatingChanged)
def isPreheating(self) -> bool: def isPreheating(self) -> bool:
return self._is_preheating return self._is_preheating
## Pre-heats the extruder before printer.
#
# \param temperature The temperature to heat the extruder to, in degrees
# Celsius.
# \param duration How long the bed should stay warm, in seconds.
@pyqtSlot(float, float) @pyqtSlot(float, float)
def preheatHotend(self, temperature: float, duration: float) -> None: def preheatHotend(self, temperature: float, duration: float) -> None:
"""Pre-heats the extruder before printer.
:param temperature: The temperature to heat the extruder to, in degrees
Celsius.
:param duration: How long the bed should stay warm, in seconds.
"""
self._printer._controller.preheatHotend(self, temperature, duration) self._printer._controller.preheatHotend(self, temperature, duration)
@pyqtSlot() @pyqtSlot()

View file

@ -48,9 +48,11 @@ class PrinterConfigurationModel(QObject):
def buildplateConfiguration(self) -> str: def buildplateConfiguration(self) -> str:
return self._buildplate_configuration return self._buildplate_configuration
## This method is intended to indicate whether the configuration is valid or not.
# The method checks if the mandatory fields are or not set
def isValid(self) -> bool: def isValid(self) -> bool:
"""This method is intended to indicate whether the configuration is valid or not.
The method checks if the mandatory fields are or not set
"""
if not self._extruder_configurations: if not self._extruder_configurations:
return False return False
for configuration in self._extruder_configurations: for configuration in self._extruder_configurations:
@ -97,9 +99,11 @@ class PrinterConfigurationModel(QObject):
return True return True
## The hash function is used to compare and create unique sets. The configuration is unique if the configuration
# of the extruders is unique (the order of the extruders matters), and the type and buildplate is the same.
def __hash__(self): def __hash__(self):
"""The hash function is used to compare and create unique sets. The configuration is unique if the configuration
of the extruders is unique (the order of the extruders matters), and the type and buildplate is the same.
"""
extruder_hash = hash(0) extruder_hash = hash(0)
first_extruder = None first_extruder = None
for configuration in self._extruder_configurations: for configuration in self._extruder_configurations:

View file

@ -163,13 +163,15 @@ class PrinterOutputModel(QObject):
def moveHead(self, x: float = 0, y: float = 0, z: float = 0, speed: float = 3000) -> None: def moveHead(self, x: float = 0, y: float = 0, z: float = 0, speed: float = 3000) -> None:
self._controller.moveHead(self, x, y, z, speed) self._controller.moveHead(self, x, y, z, speed)
## Pre-heats the heated bed of the printer.
#
# \param temperature The temperature to heat the bed to, in degrees
# Celsius.
# \param duration How long the bed should stay warm, in seconds.
@pyqtSlot(float, float) @pyqtSlot(float, float)
def preheatBed(self, temperature: float, duration: float) -> None: def preheatBed(self, temperature: float, duration: float) -> None:
"""Pre-heats the heated bed of the printer.
:param temperature: The temperature to heat the bed to, in degrees
Celsius.
:param duration: How long the bed should stay warm, in seconds.
"""
self._controller.preheatBed(self, temperature, duration) self._controller.preheatBed(self, temperature, duration)
@pyqtSlot() @pyqtSlot()
@ -200,8 +202,9 @@ class PrinterOutputModel(QObject):
self._unique_name = unique_name self._unique_name = unique_name
self.nameChanged.emit() self.nameChanged.emit()
## Update the bed temperature. This only changes it locally.
def updateBedTemperature(self, temperature: float) -> None: def updateBedTemperature(self, temperature: float) -> None:
"""Update the bed temperature. This only changes it locally."""
if self._bed_temperature != temperature: if self._bed_temperature != temperature:
self._bed_temperature = temperature self._bed_temperature = temperature
self.bedTemperatureChanged.emit() self.bedTemperatureChanged.emit()
@ -211,9 +214,10 @@ class PrinterOutputModel(QObject):
self._target_bed_temperature = temperature self._target_bed_temperature = temperature
self.targetBedTemperatureChanged.emit() self.targetBedTemperatureChanged.emit()
## Set the target bed temperature. This ensures that it's actually sent to the remote.
@pyqtSlot(float) @pyqtSlot(float)
def setTargetBedTemperature(self, temperature: float) -> None: def setTargetBedTemperature(self, temperature: float) -> None:
"""Set the target bed temperature. This ensures that it's actually sent to the remote."""
self._controller.setTargetBedTemperature(self, temperature) self._controller.setTargetBedTemperature(self, temperature)
self.updateTargetBedTemperature(temperature) self.updateTargetBedTemperature(temperature)

View file

@ -32,8 +32,9 @@ class NetworkMJPGImage(QQuickPaintedItem):
self.setAntialiasing(True) self.setAntialiasing(True)
## Ensure that close gets called when object is destroyed
def __del__(self) -> None: def __del__(self) -> None:
"""Ensure that close gets called when object is destroyed"""
self.stop() self.stop()
@ -111,7 +112,7 @@ class NetworkMJPGImage(QQuickPaintedItem):
if not self._image_reply.isFinished(): if not self._image_reply.isFinished():
self._image_reply.close() self._image_reply.close()
except Exception as e: # RuntimeError except Exception: # RuntimeError
pass # It can happen that the wrapped c++ object is already deleted. pass # It can happen that the wrapped c++ object is already deleted.
self._image_reply = None self._image_reply = None

View file

@ -84,8 +84,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
def _compressGCode(self) -> Optional[bytes]: def _compressGCode(self) -> Optional[bytes]:
self._compressing_gcode = True self._compressing_gcode = True
## Mash the data into single string
max_chars_per_line = int(1024 * 1024 / 4) # 1/4 MB per line. max_chars_per_line = int(1024 * 1024 / 4) # 1/4 MB per line.
"""Mash the data into single string"""
file_data_bytes_list = [] file_data_bytes_list = []
batched_lines = [] batched_lines = []
batched_lines_count = 0 batched_lines_count = 0
@ -145,9 +145,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent)
return request return request
## This method was only available privately before, but it was actually called from SendMaterialJob.py.
# We now have a public equivalent as well. We did not remove the private one as plugins might be using that.
def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
"""This method was only available privately before, but it was actually called from SendMaterialJob.py.
We now have a public equivalent as well. We did not remove the private one as plugins might be using that.
"""
return self._createFormPart(content_header, data, content_type) return self._createFormPart(content_header, data, content_type)
def _createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: def _createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
@ -163,8 +165,9 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
part.setBody(data) part.setBody(data)
return part return part
## Convenience function to get the username, either from the cloud or from the OS.
def _getUserName(self) -> str: def _getUserName(self) -> str:
"""Convenience function to get the username, either from the cloud or from the OS."""
# check first if we are logged in with the Ultimaker Account # check first if we are logged in with the Ultimaker Account
account = CuraApplication.getInstance().getCuraAPI().account # type: Account account = CuraApplication.getInstance().getCuraAPI().account # type: Account
if account and account.isLoggedIn: if account and account.isLoggedIn:
@ -187,15 +190,17 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
self._createNetworkManager() self._createNetworkManager()
assert (self._manager is not None) assert (self._manager is not None)
## Sends a put request to the given path.
# \param url: The path after the API prefix.
# \param data: The data to be sent in the body
# \param content_type: The content type of the body data.
# \param on_finished: The function to call when the response is received.
# \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = "application/json", def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = "application/json",
on_finished: Optional[Callable[[QNetworkReply], None]] = None, on_finished: Optional[Callable[[QNetworkReply], None]] = None,
on_progress: Optional[Callable[[int, int], None]] = None) -> None: on_progress: Optional[Callable[[int, int], None]] = None) -> None:
"""Sends a put request to the given path.
:param url: The path after the API prefix.
:param data: The data to be sent in the body
:param content_type: The content type of the body data.
:param on_finished: The function to call when the response is received.
:param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
"""
self._validateManager() self._validateManager()
request = self._createEmptyRequest(url, content_type = content_type) request = self._createEmptyRequest(url, content_type = content_type)
@ -212,10 +217,12 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
if on_progress is not None: if on_progress is not None:
reply.uploadProgress.connect(on_progress) reply.uploadProgress.connect(on_progress)
## Sends a delete request to the given path.
# \param url: The path after the API prefix.
# \param on_finished: The function to be call when the response is received.
def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
"""Sends a delete request to the given path.
:param url: The path after the API prefix.
:param on_finished: The function to be call when the response is received.
"""
self._validateManager() self._validateManager()
request = self._createEmptyRequest(url) request = self._createEmptyRequest(url)
@ -228,10 +235,12 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
reply = self._manager.deleteResource(request) reply = self._manager.deleteResource(request)
self._registerOnFinishedCallback(reply, on_finished) self._registerOnFinishedCallback(reply, on_finished)
## Sends a get request to the given path.
# \param url: The path after the API prefix.
# \param on_finished: The function to be call when the response is received.
def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
"""Sends a get request to the given path.
:param url: The path after the API prefix.
:param on_finished: The function to be call when the response is received.
"""
self._validateManager() self._validateManager()
request = self._createEmptyRequest(url) request = self._createEmptyRequest(url)
@ -244,14 +253,18 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
reply = self._manager.get(request) reply = self._manager.get(request)
self._registerOnFinishedCallback(reply, on_finished) self._registerOnFinishedCallback(reply, on_finished)
## Sends a post request to the given path.
# \param url: The path after the API prefix.
# \param data: The data to be sent in the body
# \param on_finished: The function to call when the response is received.
# \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
def post(self, url: str, data: Union[str, bytes], def post(self, url: str, data: Union[str, bytes],
on_finished: Optional[Callable[[QNetworkReply], None]], on_finished: Optional[Callable[[QNetworkReply], None]],
on_progress: Optional[Callable[[int, int], None]] = None) -> None: on_progress: Optional[Callable[[int, int], None]] = None) -> None:
"""Sends a post request to the given path.
:param url: The path after the API prefix.
:param data: The data to be sent in the body
:param on_finished: The function to call when the response is received.
:param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
"""
self._validateManager() self._validateManager()
request = self._createEmptyRequest(url) request = self._createEmptyRequest(url)
@ -318,10 +331,13 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
if on_finished is not None: if on_finished is not None:
self._onFinishedCallbacks[reply.url().toString() + str(reply.operation())] = on_finished self._onFinishedCallbacks[reply.url().toString() + str(reply.operation())] = on_finished
## This method checks if the name of the group stored in the definition container is correct.
# After updating from 3.2 to 3.3 some group names may be temporary. If there is a mismatch in the name of the group
# then all the container stacks are updated, both the current and the hidden ones.
def _checkCorrectGroupName(self, device_id: str, group_name: str) -> None: def _checkCorrectGroupName(self, device_id: str, group_name: str) -> None:
"""This method checks if the name of the group stored in the definition container is correct.
After updating from 3.2 to 3.3 some group names may be temporary. If there is a mismatch in the name of the group
then all the container stacks are updated, both the current and the hidden ones.
"""
global_container_stack = CuraApplication.getInstance().getGlobalContainerStack() global_container_stack = CuraApplication.getInstance().getGlobalContainerStack()
active_machine_network_name = CuraApplication.getInstance().getMachineManager().activeMachineNetworkKey() active_machine_network_name = CuraApplication.getInstance().getMachineManager().activeMachineNetworkKey()
if global_container_stack and device_id == active_machine_network_name: if global_container_stack and device_id == active_machine_network_name:
@ -366,32 +382,38 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
def getProperties(self): def getProperties(self):
return self._properties return self._properties
## Get the unique key of this machine
# \return key String containing the key of the machine.
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def key(self) -> str: def key(self) -> str:
"""Get the unique key of this machine
:return: key String containing the key of the machine.
"""
return self._id return self._id
## The IP address of the printer.
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def address(self) -> str: def address(self) -> str:
"""The IP address of the printer."""
return self._properties.get(b"address", b"").decode("utf-8") return self._properties.get(b"address", b"").decode("utf-8")
## Name of the printer (as returned from the ZeroConf properties)
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def name(self) -> str: def name(self) -> str:
"""Name of the printer (as returned from the ZeroConf properties)"""
return self._properties.get(b"name", b"").decode("utf-8") return self._properties.get(b"name", b"").decode("utf-8")
## Firmware version (as returned from the ZeroConf properties)
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def firmwareVersion(self) -> str: def firmwareVersion(self) -> str:
"""Firmware version (as returned from the ZeroConf properties)"""
return self._properties.get(b"firmware_version", b"").decode("utf-8") return self._properties.get(b"firmware_version", b"").decode("utf-8")
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def printerType(self) -> str: def printerType(self) -> str:
return self._properties.get(b"printer_type", b"Unknown").decode("utf-8") return self._properties.get(b"printer_type", b"Unknown").decode("utf-8")
## IP adress of this printer
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def ipAddress(self) -> str: def ipAddress(self) -> str:
"""IP adress of this printer"""
return self._address return self._address

View file

@ -2,15 +2,19 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
## Data class that represents a peripheral for a printer.
#
# Output device plug-ins may specify that the printer has a certain set of
# peripherals. This set is then possibly shown in the interface of the monitor
# stage.
class Peripheral: class Peripheral:
## Constructs the peripheral. """Data class that represents a peripheral for a printer.
# \param type A unique ID for the type of peripheral.
# \param name A human-readable name for the peripheral. Output device plug-ins may specify that the printer has a certain set of
peripherals. This set is then possibly shown in the interface of the monitor
stage.
"""
def __init__(self, peripheral_type: str, name: str) -> None: def __init__(self, peripheral_type: str, name: str) -> None:
"""Constructs the peripheral.
:param peripheral_type: A unique ID for the type of peripheral.
:param name: A human-readable name for the peripheral.
"""
self.type = peripheral_type self.type = peripheral_type
self.name = name self.name = name

View file

@ -24,8 +24,9 @@ if MYPY:
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")
## The current processing state of the backend.
class ConnectionState(IntEnum): class ConnectionState(IntEnum):
"""The current processing state of the backend."""
Closed = 0 Closed = 0
Connecting = 1 Connecting = 1
Connected = 2 Connected = 2
@ -40,17 +41,19 @@ class ConnectionType(IntEnum):
CloudConnection = 3 CloudConnection = 3
## Printer output device adds extra interface options on top of output device.
#
# The assumption is made the printer is a FDM printer.
#
# Note that a number of settings are marked as "final". This is because decorators
# are not inherited by children. To fix this we use the private counter part of those
# functions to actually have the implementation.
#
# For all other uses it should be used in the same way as a "regular" OutputDevice.
@signalemitter @signalemitter
class PrinterOutputDevice(QObject, OutputDevice): class PrinterOutputDevice(QObject, OutputDevice):
"""Printer output device adds extra interface options on top of output device.
The assumption is made the printer is a FDM printer.
Note that a number of settings are marked as "final". This is because decorators
are not inherited by children. To fix this we use the private counter part of those
functions to actually have the implementation.
For all other uses it should be used in the same way as a "regular" OutputDevice.
"""
printersChanged = pyqtSignal() printersChanged = pyqtSignal()
connectionStateChanged = pyqtSignal(str) connectionStateChanged = pyqtSignal(str)
@ -184,26 +187,30 @@ class PrinterOutputDevice(QObject, OutputDevice):
if self._monitor_item is None: if self._monitor_item is None:
self._monitor_item = QtApplication.getInstance().createQmlComponent(self._monitor_view_qml_path, {"OutputDevice": self}) self._monitor_item = QtApplication.getInstance().createQmlComponent(self._monitor_view_qml_path, {"OutputDevice": self})
## Attempt to establish connection
def connect(self) -> None: def connect(self) -> None:
"""Attempt to establish connection"""
self.setConnectionState(ConnectionState.Connecting) self.setConnectionState(ConnectionState.Connecting)
self._update_timer.start() self._update_timer.start()
## Attempt to close the connection
def close(self) -> None: def close(self) -> None:
"""Attempt to close the connection"""
self._update_timer.stop() self._update_timer.stop()
self.setConnectionState(ConnectionState.Closed) self.setConnectionState(ConnectionState.Closed)
## Ensure that close gets called when object is destroyed
def __del__(self) -> None: def __del__(self) -> None:
"""Ensure that close gets called when object is destroyed"""
self.close() self.close()
@pyqtProperty(bool, notify = acceptsCommandsChanged) @pyqtProperty(bool, notify = acceptsCommandsChanged)
def acceptsCommands(self) -> bool: def acceptsCommands(self) -> bool:
return self._accepts_commands return self._accepts_commands
## Set a flag to signal the UI that the printer is not (yet) ready to receive commands
def _setAcceptsCommands(self, accepts_commands: bool) -> None: def _setAcceptsCommands(self, accepts_commands: bool) -> None:
"""Set a flag to signal the UI that the printer is not (yet) ready to receive commands"""
if self._accepts_commands != accepts_commands: if self._accepts_commands != accepts_commands:
self._accepts_commands = accepts_commands self._accepts_commands = accepts_commands
@ -241,16 +248,20 @@ class PrinterOutputDevice(QObject, OutputDevice):
# At this point there may be non-updated configurations # At this point there may be non-updated configurations
self._updateUniqueConfigurations() self._updateUniqueConfigurations()
## Set the device firmware name
#
# \param name The name of the firmware.
def _setFirmwareName(self, name: str) -> None: def _setFirmwareName(self, name: str) -> None:
"""Set the device firmware name
:param name: The name of the firmware.
"""
self._firmware_name = name self._firmware_name = name
## Get the name of device firmware
#
# This name can be used to define device type
def getFirmwareName(self) -> Optional[str]: def getFirmwareName(self) -> Optional[str]:
"""Get the name of device firmware
This name can be used to define device type
"""
return self._firmware_name return self._firmware_name
def getFirmwareUpdater(self) -> Optional["FirmwareUpdater"]: def getFirmwareUpdater(self) -> Optional["FirmwareUpdater"]:

View file

@ -10,15 +10,19 @@ class NoProfileException(Exception):
pass pass
## A type of plug-ins that reads profiles from a file.
#
# The profile is then stored as instance container of the type user profile.
class ProfileReader(PluginObject): class ProfileReader(PluginObject):
"""A type of plug-ins that reads profiles from a file.
The profile is then stored as instance container of the type user profile.
"""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
## Read profile data from a file and return a filled profile.
#
# \return \type{Profile|Profile[]} The profile that was obtained from the file or a list of Profiles.
def read(self, file_name): def read(self, file_name):
"""Read profile data from a file and return a filled profile.
:return: :type{Profile|Profile[]} The profile that was obtained from the file or a list of Profiles.
"""
raise NotImplementedError("Profile reader plug-in was not correctly implemented. The read function was not implemented.") raise NotImplementedError("Profile reader plug-in was not correctly implemented. The read function was not implemented.")

View file

@ -3,23 +3,29 @@
from UM.PluginObject import PluginObject from UM.PluginObject import PluginObject
## Base class for profile writer plugins.
#
# This class defines a write() function to write profiles to files with.
class ProfileWriter(PluginObject): class ProfileWriter(PluginObject):
## Initialises the profile writer. """Base class for profile writer plugins.
#
# This currently doesn't do anything since the writer is basically static. This class defines a write() function to write profiles to files with.
"""
def __init__(self): def __init__(self):
"""Initialises the profile writer.
This currently doesn't do anything since the writer is basically static.
"""
super().__init__() super().__init__()
## Writes a profile to the specified file path.
#
# The profile writer may write its own file format to the specified file.
#
# \param path \type{string} The file to output to.
# \param profiles \type{Profile} or \type{List} The profile(s) to write to the file.
# \return \code True \endcode if the writing was successful, or \code
# False \endcode if it wasn't.
def write(self, path, profiles): def write(self, path, profiles):
"""Writes a profile to the specified file path.
The profile writer may write its own file format to the specified file.
:param path: :type{string} The file to output to.
:param profiles: :type{Profile} or :type{List} The profile(s) to write to the file.
:return: True if the writing was successful, or False if it wasn't.
"""
raise NotImplementedError("Profile writer plugin was not correctly implemented. No write was specified.") raise NotImplementedError("Profile writer plugin was not correctly implemented. No write was specified.")

View file

@ -2,8 +2,9 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
from cura.Scene.CuraSceneNode import CuraSceneNode from cura.Scene.CuraSceneNode import CuraSceneNode
## Make a SceneNode build plate aware CuraSceneNode objects all have this decorator.
class BuildPlateDecorator(SceneNodeDecorator): class BuildPlateDecorator(SceneNodeDecorator):
"""Make a SceneNode build plate aware CuraSceneNode objects all have this decorator."""
def __init__(self, build_plate_number: int = -1) -> None: def __init__(self, build_plate_number: int = -1) -> None:
super().__init__() super().__init__()
self._build_plate_number = build_plate_number self._build_plate_number = build_plate_number

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