mirror of
https://github.com/Ultimaker/Cura.git
synced 2025-07-18 20:28:01 -06:00
Merge branch 'libArachne_rebased' into back_pressure_compensation
This commit is contained in:
commit
fbbec8c012
5339 changed files with 559595 additions and 563821 deletions
2
.github/ISSUE_TEMPLATE.md
vendored
2
.github/ISSUE_TEMPLATE.md
vendored
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
name: Bug report
|
||||
name: Old Bug report
|
||||
about: Create a report to help us fix issues.
|
||||
title: ''
|
||||
labels: 'Type: Bug'
|
||||
|
|
49
.github/ISSUE_TEMPLATE/bug-report.md
vendored
49
.github/ISSUE_TEMPLATE/bug-report.md
vendored
|
@ -1,49 +0,0 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us fix issues.
|
||||
title: ''
|
||||
labels: 'Type: Bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
Processing an issue will go much faster when this is filled out, and issues which do not use this template WILL BE REMOVED and no fix will be considered!
|
||||
|
||||
Before filing, PLEASE check if the issue already exists (either open or closed) by using the search bar on the issues page. If it does, comment there. Even if it's closed, we can reopen it based on your comment.
|
||||
|
||||
Also, please note the application version in the title of the issue. For example: "[3.2.1] Cannot connect to 3rd-party printer". Please do NOT write things like "Request:" or "[BUG]" in the title; this is what labels are for.
|
||||
|
||||
Thank you for using Cura!
|
||||
-->
|
||||
|
||||
**Application version**
|
||||
(The version of the application this issue occurs with.)
|
||||
|
||||
**Platform**
|
||||
(Information about the operating system the issue occurs on. Include at least the operating system and maybe GPU.)
|
||||
|
||||
**Printer**
|
||||
(Which printer was selected in Cura?)
|
||||
|
||||
**Reproduction steps**
|
||||
1. (Something you did.)
|
||||
2. (Something you did next.)
|
||||
|
||||
**Screenshot(s)**
|
||||
(Image showing the problem, perhaps before/after images.)
|
||||
|
||||
**Actual results**
|
||||
(What happens after the above steps have been followed.)
|
||||
|
||||
**Expected results**
|
||||
(What should happen after the above steps have been followed.)
|
||||
|
||||
**Project file**
|
||||
(For slicing bugs, provide a project which clearly shows the bug, by going to File->Save. 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
82
.github/ISSUE_TEMPLATE/bugreport.yaml
vendored
Normal file
|
@ -0,0 +1,82 @@
|
|||
name: Bug Report
|
||||
description: Create a report to help us fix issues.
|
||||
labels: "Type: Bug"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Thank you for using Cura and wanting to report a bug.**
|
||||
|
||||
Before filing, please check if the issue already exists (either open or closed) by using the search bar on the issues page. If it does, comment there. Even if it's closed, we can reopen it based on your comment.
|
||||
|
||||
Also, please note the application version in the title of the issue "For example (3.2.1) Cannot connect to 3rd-party printer". Please do not write things like **Request** or **BUG** in the title, this is what labels are for.
|
||||
- type: input
|
||||
attributes:
|
||||
label: Application Version
|
||||
description: The version of Cura this issue occurs with.
|
||||
placeholder: 4.9.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Information about the operating system the issue occurs on. Include at least the operating system and maybe GPU.
|
||||
placeholder: Windows 10
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Printer
|
||||
description: Which printer was selected in Cura?
|
||||
placeholder: Ultimaker S5
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: Tell us what you did!
|
||||
placeholder: |
|
||||
1. Something you did
|
||||
2. Something you did next
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Actual results
|
||||
description: What happens after the above steps have been followed.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected results
|
||||
description: What should happen after the above steps have been followed.
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please be sure to add the following files:
|
||||
* For slicing issues, upload a **project file** that clearly shows the bug.
|
||||
To save a project file go to `File -> Save project`. Please make sure to .zip your project file. For big files you may need to use WeTransfer or similar file sharing sites.
|
||||
G-code files are not project files!
|
||||
* **Screenshots** of showing the problem, perhaps before/after images.
|
||||
* A **log file** for crashes and similar issues.
|
||||
You can find your log file here:
|
||||
Windows: `%APPDATA%\cura\<Cura version>\cura.log` or usually `C:\Users\\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
|
||||
MacOS: `$USER/Library/Application Support/cura/<Cura version>/cura.log`
|
||||
Ubuntu/Linus: `$USER/.local/share/cura/<Cura version>/cura.log`
|
||||
|
||||
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist of files to include
|
||||
options:
|
||||
- label: Log file
|
||||
- label: Project file
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information & file uploads
|
||||
description: You can add these files and additional information that is relevant to the issue in the comments below.
|
||||
validations:
|
||||
required: true
|
||||
|
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Have questions or need support?
|
||||
url: https://community.ultimaker.com/
|
||||
about: Please get in touch on our Ultimaker Community Forum!
|
23
.github/ISSUE_TEMPLATE/feature_request.md
vendored
23
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
@ -1,23 +0,0 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: 'Type: New Feature'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
(A clear and concise description of what the problem is. Ex. I'm always frustrated when [...])
|
||||
|
||||
**Describe the solution you'd like**
|
||||
(A clear and concise description of what you want to happen. If possible, describe why you think this is a good solution.)
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
(A clear and concise description of any alternative solutions or features you've considered. Again, if possible, think about why these alternatives are not working out.)
|
||||
|
||||
**Affected users and/or printers**
|
||||
(Who do you think will benefit from this? Is everyone going to benefit from these changes? Or specific kinds of users?)
|
||||
|
||||
**Additional context**
|
||||
(Add any other context or screenshots about the feature request here.)
|
44
.github/ISSUE_TEMPLATE/featurerequest.yaml
vendored
Normal file
44
.github/ISSUE_TEMPLATE/featurerequest.yaml
vendored
Normal file
|
@ -0,0 +1,44 @@
|
|||
name: Feature Request
|
||||
description: Suggest an idea for this project.
|
||||
labels: "Type: New Feature"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Thank you for using Cura and wanting to suggest a new feature.**
|
||||
|
||||
Before filing, please check if the feature request already exists (either open or closed) by using the search bar on the issues page. If it does, comment there. Even if it's closed, we can reopen it based on your comment.
|
||||
|
||||
Please do not write things like **Request** or **BUG** in the title, this is what labels are for.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
description: Please describe a clear and concise description of what the problem is.
|
||||
placeholder: I'm always frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen. If possible, describe why you think this is a good solution.
|
||||
placeholder: I believe this will solve...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered. If possible, think about why these alternatives are not working out.
|
||||
placeholder: The alternatives I've considered are...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Affected users and/or printers
|
||||
description: Who do you think will benefit from this? Is everyone going to benefit from these changes? Or specific kinds of users?
|
||||
placeholder: It will affect...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information & file uploads
|
||||
description: You can add pictures or files to visualize your feature request in the comments below.
|
13
.github/no-response.yml
vendored
Normal file
13
.github/no-response.yml
vendored
Normal 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.
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
name: CI/CD
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
|
@ -10,11 +10,12 @@ on:
|
|||
pull_request:
|
||||
jobs:
|
||||
build:
|
||||
name: Build and test
|
||||
runs-on: ubuntu-latest
|
||||
container: ultimaker/cura-build-environment
|
||||
steps:
|
||||
- name: Checkout Cura
|
||||
uses: actions/checkout@v2
|
||||
- name: Build and test
|
||||
- name: Build
|
||||
run: docker/build.sh
|
||||
- name: Test
|
||||
run: docker/test.sh
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -53,6 +53,8 @@ plugins/GodMode
|
|||
plugins/OctoPrintPlugin
|
||||
plugins/ProfileFlattener
|
||||
plugins/SettingsGuide
|
||||
plugins/SettingsGuide2
|
||||
plugins/SVGToolpathReader
|
||||
plugins/X3GWriter
|
||||
|
||||
#Build stuff
|
||||
|
@ -76,3 +78,5 @@ CuraEngine
|
|||
|
||||
#Prevents import failures when plugin running tests
|
||||
plugins/__init__.py
|
||||
|
||||
/venv
|
||||
|
|
11
CITATION.cff
Normal file
11
CITATION.cff
Normal file
|
@ -0,0 +1,11 @@
|
|||
# YAML 1.2
|
||||
---
|
||||
authors:
|
||||
cff-version: "1.1.0"
|
||||
date-released: 2021-06-28
|
||||
license: "LGPL-3.0"
|
||||
message: "If you use this software, please cite it using these metadata."
|
||||
repository-code: "https://github.com/ultimaker/cura/"
|
||||
title: "Ultimaker Cura"
|
||||
version: "4.10.0"
|
||||
...
|
|
@ -16,6 +16,8 @@ if(CURA_DEBUGMODE)
|
|||
set(_cura_debugmode "ON")
|
||||
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_DISPLAY_NAME "Ultimaker Cura" CACHE STRING "Display 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_ACCOUNT_API_ROOT "" CACHE STRING "Alternative Cura cloud account API version")
|
||||
set(CURA_MARKETPLACE_ROOT "" CACHE STRING "Alternative Marketplace location")
|
||||
set(CURA_DIGITAL_FACTORY_URL "" CACHE STRING "Alternative Digital Factory location")
|
||||
|
||||
configure_file(${CMAKE_SOURCE_DIR}/cura.desktop.in ${CMAKE_BINARY_DIR}/cura.desktop @ONLY)
|
||||
configure_file(${CMAKE_SOURCE_DIR}/com.ultimaker.cura.desktop.in ${CMAKE_BINARY_DIR}/com.ultimaker.cura.desktop @ONLY)
|
||||
|
||||
configure_file(cura/CuraVersion.py.in CuraVersion.py @ONLY)
|
||||
|
||||
|
||||
# FIXME: 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
|
||||
# module is copied from the CMake repository here so in CMake <3.12 we can still use it.
|
||||
if(${CMAKE_VERSION} VERSION_LESS 3.12)
|
||||
# Use FindPythonInterp and FindPythonLibs for CMake <3.12
|
||||
find_package(PythonInterp 3 REQUIRED)
|
||||
# FIXME: The new FindPython3 finds the system's Python3.6 reather than the Python3.5 that we built for Cura's environment.
|
||||
# So we're using the old method here, with FindPythonInterp for now.
|
||||
find_package(PythonInterp 3 REQUIRED)
|
||||
|
||||
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_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})
|
||||
|
||||
if(NOT ${URANIUM_DIR} STREQUAL "")
|
||||
set(CMAKE_MODULE_PATH "${CMAKE_MODULE_PATH};${URANIUM_DIR}/cmake")
|
||||
|
@ -58,7 +53,9 @@ if(NOT ${URANIUM_SCRIPTS_DIR} STREQUAL "")
|
|||
# Extract Strings
|
||||
add_custom_target(extract-messages ${URANIUM_SCRIPTS_DIR}/extract-messages ${CMAKE_SOURCE_DIR} cura)
|
||||
# Build Translations
|
||||
CREATE_TRANSLATION_TARGETS()
|
||||
if(${GENERATE_TRANSLATIONS})
|
||||
CREATE_TRANSLATION_TARGETS()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
|
||||
|
@ -85,11 +82,11 @@ if(NOT APPLE AND NOT WIN32)
|
|||
install(FILES ${CMAKE_BINARY_DIR}/CuraVersion.py
|
||||
DESTINATION lib${LIB_SUFFIX}/python${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/site-packages/cura)
|
||||
endif()
|
||||
install(FILES ${CMAKE_BINARY_DIR}/cura.desktop
|
||||
install(FILES ${CMAKE_BINARY_DIR}/com.ultimaker.cura.desktop
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
|
||||
install(FILES ${CMAKE_SOURCE_DIR}/resources/images/cura-icon.png
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/128x128/apps/)
|
||||
install(FILES cura.appdata.xml
|
||||
install(FILES com.ultimaker.cura.appdata.xml
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo)
|
||||
install(FILES cura.sharedmimeinfo
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/mime/packages/
|
||||
|
|
20
README.md
20
README.md
|
@ -1,6 +1,8 @@
|
|||
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.
|
||||
|
||||

|
||||
|
||||
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)
|
||||
* The Cura GUI log file, located at
|
||||
* `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
|
||||
* `$USER/Library/Application Support/cura/<Cura version>/cura.log` (OSX)
|
||||
* `$USER/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux)
|
||||
* `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
|
||||
* `$HOME/Library/Application Support/cura/<Cura version>/cura.log` (OSX)
|
||||
* `$HOME/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux)
|
||||
|
||||
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
|
||||
|
||||
For additional support, you could also ask in the #cura channel on FreeNode IRC. For help with development, there is also the #cura-dev channel.
|
||||
For additional support, you could also ask in the [#cura channel](https://web.libera.chat/#cura) on [libera.chat](https://libera.chat/). For help with development, there is also the [#cura-dev channel](https://web.libera.chat/#cura-dev).
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
|
@ -24,9 +26,15 @@ Dependencies
|
|||
* [PySerial](https://github.com/pyserial/pyserial) Only required for USB printing support.
|
||||
* [python-zeroconf](https://github.com/jstasiak/python-zeroconf) Only required to detect mDNS-enabled printers.
|
||||
|
||||
For a list of required Python packages, with their recommended version, see `requirements.txt`.
|
||||
|
||||
This list is not exhaustive at the moment, please check the links in the next section for more details.
|
||||
|
||||
Build scripts
|
||||
-------------
|
||||
Please 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
|
||||
-------------
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
# 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.
|
||||
# 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.
|
||||
|
@ -81,7 +83,9 @@ foreach(_plugin_json_path ${_plugin_json_list})
|
|||
endif()
|
||||
|
||||
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)
|
||||
install(DIRECTORY ${_rel_plugin_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})
|
||||
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
|
||||
-d ${CMAKE_CURRENT_SOURCE_DIR}/resources/bundled_packages
|
||||
${_plugin_dir_name}
|
||||
|
|
|
@ -4,18 +4,11 @@
|
|||
include(CTest)
|
||||
include(CMakeParseArguments)
|
||||
|
||||
# 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
|
||||
# module is copied from the CMake repository here so in CMake <3.12 we can still use it.
|
||||
if(${CMAKE_VERSION} VERSION_LESS 3.12)
|
||||
# Use FindPythonInterp and FindPythonLibs for CMake <3.12
|
||||
find_package(PythonInterp 3 REQUIRED)
|
||||
# FIXME: The new FindPython3 finds the system's Python3.6 reather than the Python3.5 that we built for Cura's environment.
|
||||
# So we're using the old method here, with FindPythonInterp for now.
|
||||
find_package(PythonInterp 3 REQUIRED)
|
||||
|
||||
set(Python3_EXECUTABLE ${PYTHON_EXECUTABLE})
|
||||
else()
|
||||
# Use FindPython3 for CMake >=3.12
|
||||
find_package(Python3 REQUIRED COMPONENTS Interpreter Development)
|
||||
endif()
|
||||
set(Python3_EXECUTABLE ${PYTHON_EXECUTABLE})
|
||||
|
||||
add_custom_target(test-verbose COMMAND ${CMAKE_CTEST_COMMAND} --verbose)
|
||||
|
||||
|
@ -56,6 +49,14 @@ function(cura_add_test)
|
|||
endif()
|
||||
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(
|
||||
NAME "invalid-imports"
|
||||
|
@ -74,13 +75,6 @@ foreach(_plugin ${_plugins})
|
|||
endif()
|
||||
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(
|
||||
NAME "shortcut-keys"
|
||||
|
|
|
@ -11,11 +11,13 @@ import os
|
|||
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:
|
||||
"""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 = []
|
||||
for root, dir_names, file_names in os.walk(work_dir):
|
||||
for file_name in file_names:
|
||||
|
@ -24,12 +26,14 @@ def find_json_files(work_dir: str) -> 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:
|
||||
"""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:
|
||||
with open(file_path, "r", encoding = "utf-8") as f:
|
||||
package_dict = json.load(f, object_hook = collections.OrderedDict)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Copyright 2016 Richard Hughes <richard@hughsie.com> -->
|
||||
<component type="desktop">
|
||||
<id>cura.desktop</id>
|
||||
<id>com.ultimaker.cura.desktop</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>LGPL-3.0 and CC-BY-SA-4.0</project_license>
|
||||
<name>Cura</name>
|
||||
|
@ -24,7 +24,9 @@
|
|||
</ul>
|
||||
</description>
|
||||
<screenshots>
|
||||
<screenshot type="default" width="1280" height="720">http://software.ultimaker.com/Cura.png</screenshot>
|
||||
<screenshot type="default">
|
||||
<image>https://raw.githubusercontent.com/Ultimaker/Cura/master/screenshot.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<url type="homepage">https://ultimaker.com/en/products/cura-software?utm_source=cura&utm_medium=software&utm_campaign=resources</url>
|
||||
<translation type="gettext">Cura</translation>
|
|
@ -1,15 +1,16 @@
|
|||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# 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 cura import UltimakerCloudAuthentication
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
from cura.OAuth2.AuthorizationService import AuthorizationService
|
||||
from cura.OAuth2.Models import OAuth2Settings
|
||||
from cura.UltimakerCloud import UltimakerCloudConstants
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
@ -17,38 +18,73 @@ if TYPE_CHECKING:
|
|||
i18n_catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
## The account API provides a version-proof bridge to use Ultimaker Accounts
|
||||
#
|
||||
# Usage:
|
||||
# ``from cura.API import CuraAPI
|
||||
# api = CuraAPI()
|
||||
# api.account.login()
|
||||
# api.account.logout()
|
||||
# api.account.userProfile # Who is logged in``
|
||||
#
|
||||
class SyncState:
|
||||
"""QML: Cura.AccountSyncState"""
|
||||
SYNCING = 0
|
||||
SUCCESS = 1
|
||||
ERROR = 2
|
||||
IDLE = 3
|
||||
|
||||
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)
|
||||
"""Signal emitted when user logged in or out"""
|
||||
|
||||
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:
|
||||
super().__init__(parent)
|
||||
self._application = application
|
||||
self._new_cloud_printers_detected = False
|
||||
|
||||
self._error_message = None # type: Optional[Message]
|
||||
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._oauth_root = UltimakerCloudAuthentication.CuraCloudAccountAPIRoot
|
||||
self._oauth_root = UltimakerCloudConstants.CuraCloudAccountAPIRoot
|
||||
|
||||
self._oauth_settings = OAuth2Settings(
|
||||
OAUTH_SERVER_URL= self._oauth_root,
|
||||
CALLBACK_PORT=self._callback_port,
|
||||
CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port),
|
||||
CLIENT_ID="um----------------------------ultimaker_cura",
|
||||
CLIENT_SCOPES="account.user.read drive.backup.read drive.backup.write packages.download "
|
||||
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write "
|
||||
"cura.printjob.read cura.printjob.write cura.mesh.read cura.mesh.write",
|
||||
CLIENT_SCOPES=self.CLIENT_SCOPES,
|
||||
AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data",
|
||||
AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root),
|
||||
AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root)
|
||||
|
@ -56,6 +92,16 @@ class Account(QObject):
|
|||
|
||||
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:
|
||||
self._authorization_service.initialize(self._application.getPreferences())
|
||||
self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged)
|
||||
|
@ -63,12 +109,64 @@ class Account(QObject):
|
|||
self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged)
|
||||
self._authorization_service.loadAuthDataFromPreferences()
|
||||
|
||||
@pyqtProperty(int, notify=syncStateChanged)
|
||||
def syncState(self):
|
||||
return self._sync_state
|
||||
|
||||
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):
|
||||
self.accessTokenChanged.emit()
|
||||
|
||||
## Returns a boolean indicating whether the given authentication is applied against staging or not.
|
||||
@property
|
||||
def is_staging(self) -> bool:
|
||||
"""Indication whether the given authentication is applied against staging or not."""
|
||||
|
||||
return "staging" in self._oauth_root
|
||||
|
||||
@pyqtProperty(bool, notify=loginStateChanged)
|
||||
|
@ -79,22 +177,67 @@ class Account(QObject):
|
|||
if error_message:
|
||||
if self._error_message:
|
||||
self._error_message.hide()
|
||||
self._error_message = Message(error_message, title = i18n_catalog.i18nc("@info:title", "Login failed"))
|
||||
Logger.log("w", "Failed to login: %s", error_message)
|
||||
self._error_message = Message(error_message,
|
||||
title = i18n_catalog.i18nc("@info:title", "Login failed"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
self._error_message.show()
|
||||
self._logged_in = False
|
||||
self.loginStateChanged.emit(False)
|
||||
if self._update_timer.isActive():
|
||||
self._update_timer.stop()
|
||||
return
|
||||
|
||||
if self._logged_in != logged_in:
|
||||
self._logged_in = 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()
|
||||
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:
|
||||
# Nothing to do, user already logged in.
|
||||
return
|
||||
self._authorization_service.startAuthorizationFlow()
|
||||
if force_logout_before_login:
|
||||
self.logout()
|
||||
else:
|
||||
# Nothing to do, user already logged in.
|
||||
return
|
||||
self._authorization_service.startAuthorizationFlow(force_logout_before_login)
|
||||
|
||||
@pyqtProperty(str, notify=loginStateChanged)
|
||||
def userName(self):
|
||||
|
@ -114,15 +257,44 @@ class Account(QObject):
|
|||
def accessToken(self) -> Optional[str]:
|
||||
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)
|
||||
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()
|
||||
if not user_profile:
|
||||
return None
|
||||
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()
|
||||
def logout(self) -> None:
|
||||
if not self._logged_in:
|
||||
|
|
|
@ -8,28 +8,37 @@ if TYPE_CHECKING:
|
|||
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:
|
||||
"""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:
|
||||
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]]]:
|
||||
"""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()
|
||||
|
||||
## 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:
|
||||
"""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)
|
||||
|
|
41
cura/API/ConnectionStatus.py
Normal file
41
cura/API/ConnectionStatus.py
Normal 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()
|
||||
|
|
@ -7,32 +7,43 @@ if TYPE_CHECKING:
|
|||
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:
|
||||
"""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:
|
||||
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:
|
||||
"""Add items to the sidebar context menu.
|
||||
|
||||
:param menu_item: dict containing the menu item to add.
|
||||
"""
|
||||
|
||||
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:
|
||||
"""Get all custom items currently added to the sidebar context menu.
|
||||
|
||||
:return: List containing all custom context menu items.
|
||||
"""
|
||||
|
||||
return self.application.getSidebarCustomMenuItems()
|
||||
|
|
|
@ -9,18 +9,22 @@ if TYPE_CHECKING:
|
|||
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:
|
||||
"""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:
|
||||
# API methods specific to the settings portion of the UI
|
||||
|
|
|
@ -5,6 +5,7 @@ from typing import Optional, TYPE_CHECKING
|
|||
from PyQt5.QtCore import QObject, pyqtProperty
|
||||
|
||||
from cura.API.Backups import Backups
|
||||
from cura.API.ConnectionStatus import ConnectionStatus
|
||||
from cura.API.Interface import Interface
|
||||
from cura.API.Account import Account
|
||||
|
||||
|
@ -12,13 +13,14 @@ if TYPE_CHECKING:
|
|||
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):
|
||||
"""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.
|
||||
__instance = None # type: "CuraAPI"
|
||||
|
@ -39,12 +41,12 @@ class CuraAPI(QObject):
|
|||
def __init__(self, application: Optional["CuraApplication"] = None) -> None:
|
||||
super().__init__(parent = CuraAPI._application)
|
||||
|
||||
# Accounts API
|
||||
self._account = Account(self._application)
|
||||
|
||||
# Backups API
|
||||
self._backups = Backups(self._application)
|
||||
|
||||
self._connectionStatus = ConnectionStatus()
|
||||
|
||||
# Interface API
|
||||
self._interface = Interface(self._application)
|
||||
|
||||
|
@ -53,12 +55,22 @@ class CuraAPI(QObject):
|
|||
|
||||
@pyqtProperty(QObject, constant = True)
|
||||
def account(self) -> "Account":
|
||||
"""Accounts API"""
|
||||
|
||||
return self._account
|
||||
|
||||
@pyqtProperty(QObject, constant = True)
|
||||
def connectionStatus(self) -> "ConnectionStatus":
|
||||
return self._connectionStatus
|
||||
|
||||
@property
|
||||
def backups(self) -> "Backups":
|
||||
"""Backups API"""
|
||||
|
||||
return self._backups
|
||||
|
||||
@property
|
||||
def interface(self) -> "Interface":
|
||||
"""Interface API"""
|
||||
|
||||
return self._interface
|
||||
|
|
|
@ -13,7 +13,7 @@ DEFAULT_CURA_DEBUG_MODE = False
|
|||
# Each release has a fixed SDK version coupled with it. It doesn't make sense to make it configurable because, for
|
||||
# example Cura 3.2 with SDK version 6.1 will not work. So the SDK version is hard-coded here and left out of the
|
||||
# CuraVersion.py.in template.
|
||||
CuraSDKVersion = "7.1.0"
|
||||
CuraSDKVersion = "7.6.0"
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraAppName # type: ignore
|
||||
|
@ -46,6 +46,10 @@ except ImportError:
|
|||
# Various convenience flags indicating what kind of Cura build it is.
|
||||
__ENTERPRISE_VERSION_TYPE = "enterprise"
|
||||
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:
|
||||
from cura.CuraVersion import CuraAppDisplayName # type: ignore
|
||||
|
|
|
@ -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.
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
from UM.Decorators import deprecated
|
||||
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
|
||||
from UM.Logger import Logger
|
||||
from UM.Math.Polygon import Polygon
|
||||
|
@ -16,21 +17,24 @@ from collections import namedtuple
|
|||
import numpy
|
||||
import copy
|
||||
|
||||
|
||||
## Return object for bestSpot
|
||||
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:
|
||||
"""
|
||||
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]
|
||||
|
||||
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
|
||||
world_x, world_y = int(x * self._scale), int(y * self._scale)
|
||||
self._shape = (world_y, world_x)
|
||||
|
@ -42,14 +46,22 @@ class Arrange:
|
|||
self._last_priority = 0
|
||||
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
|
||||
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.centerFirst()
|
||||
|
||||
|
@ -71,8 +83,11 @@ class Arrange:
|
|||
# After scaling (like up to 0.1 mm) the node might not have points
|
||||
if not points.size:
|
||||
continue
|
||||
|
||||
shape_arr = ShapeArray.fromPolygon(points, scale = scale)
|
||||
try:
|
||||
shape_arr = ShapeArray.fromPolygon(points, scale = scale)
|
||||
except ValueError:
|
||||
Logger.logException("w", "Unable to create polygon")
|
||||
continue
|
||||
arranger.place(0, 0, shape_arr)
|
||||
|
||||
# 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)
|
||||
return arranger
|
||||
|
||||
## This resets the optimization for finding location based on size
|
||||
def resetLastPriority(self):
|
||||
"""This resets the optimization for finding location based on size"""
|
||||
|
||||
self._last_priority = 0
|
||||
|
||||
## Find placement for a node (using offset shape) and place it (using hull shape)
|
||||
# return the nodes that should be placed
|
||||
# \param node
|
||||
# \param offset_shape_arr ShapeArray with offset, for placing the shape
|
||||
# \param hull_shape_arr ShapeArray without offset, used to find location
|
||||
def findNodePlacement(self, node: SceneNode, offset_shape_arr: ShapeArray, hull_shape_arr: ShapeArray, step = 1):
|
||||
@deprecated("Use the functions in Nest2dArrange instead", "4.8")
|
||||
def findNodePlacement(self, node: SceneNode, offset_shape_arr: ShapeArray, hull_shape_arr: ShapeArray, step = 1) -> bool:
|
||||
"""Find placement for a node (using offset shape) and place it (using hull shape)
|
||||
|
||||
:param node: The node to be placed
|
||||
: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(
|
||||
hull_shape_arr, start_prio = self._last_priority, step = step)
|
||||
x, y = best_spot.x, best_spot.y
|
||||
|
@ -119,29 +140,32 @@ class Arrange:
|
|||
node.setPosition(Vector(200, center_y, 100))
|
||||
return found_spot
|
||||
|
||||
## Fill priority, center is best. Lower value is better
|
||||
# This is a strategy for the arranger.
|
||||
def centerFirst(self):
|
||||
"""Fill priority, center is best. Lower value is better. """
|
||||
|
||||
# Square distance: creates a more round shape
|
||||
self._priority = numpy.fromfunction(
|
||||
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.sort()
|
||||
|
||||
## Fill priority, back is best. Lower value is better
|
||||
# This is a strategy for the arranger.
|
||||
def backFirst(self):
|
||||
"""Fill priority, back is best. Lower value is better """
|
||||
|
||||
self._priority = numpy.fromfunction(
|
||||
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.sort()
|
||||
|
||||
## Return the amount of "penalty points" for polygon, which is the sum of priority
|
||||
# None if occupied
|
||||
# \param x x-coordinate to check shape
|
||||
# \param y y-coordinate
|
||||
# \param shape_arr the ShapeArray object to place
|
||||
def checkShape(self, x, y, shape_arr):
|
||||
def checkShape(self, x, y, shape_arr) -> Optional[numpy.ndarray]:
|
||||
"""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 to check shape
|
||||
:param shape_arr: the shape array object to place
|
||||
:return: None if occupied
|
||||
"""
|
||||
|
||||
x = int(self._scale * x)
|
||||
y = int(self._scale * y)
|
||||
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]]
|
||||
return numpy.sum(prio_slice[numpy.where(shape_arr.arr == 1)])
|
||||
|
||||
## Find "best" spot for ShapeArray
|
||||
# Return namedtuple with properties x, y, penalty_points, priority.
|
||||
# \param shape_arr ShapeArray
|
||||
# \param start_prio Start with this priority value (and skip the ones before)
|
||||
# \param step Slicing value, higher = more skips = faster but less accurate
|
||||
def bestSpot(self, shape_arr, start_prio = 0, step = 1):
|
||||
def bestSpot(self, shape_arr, start_prio = 0, step = 1) -> LocationSuggestion:
|
||||
"""Find "best" spot for ShapeArray
|
||||
|
||||
:param shape_arr: shape array
|
||||
:param start_prio: Start with this priority value (and skip the ones before)
|
||||
: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)
|
||||
if start_idx_list:
|
||||
try:
|
||||
|
@ -179,6 +206,7 @@ class Arrange:
|
|||
start_idx = 0
|
||||
else:
|
||||
start_idx = 0
|
||||
priority = 0
|
||||
for priority in self._priority_unique_values[start_idx::step]:
|
||||
tryout_idx = numpy.where(self._priority == priority)
|
||||
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 = 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):
|
||||
"""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)
|
||||
y = int(self._scale * y)
|
||||
offset_x = x + self._offset_x + shape_arr.offset_x
|
||||
|
|
|
@ -18,8 +18,9 @@ from cura.Arranging.ShapeArray import ShapeArray
|
|||
from typing import List
|
||||
|
||||
|
||||
## Do arrangements on multiple build plates (aka builtiplexer)
|
||||
class ArrangeArray:
|
||||
"""Do arrangements on multiple build plates (aka builtiplexer)"""
|
||||
|
||||
def __init__(self, x: int, y: int, fixed_nodes: List[SceneNode]) -> None:
|
||||
self._x = x
|
||||
self._y = y
|
||||
|
@ -146,6 +147,8 @@ class ArrangeObjectsAllBuildPlatesJob(Job):
|
|||
status_message.hide()
|
||||
|
||||
if not found_solution_for_all:
|
||||
no_full_solution_message = Message(i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"),
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"))
|
||||
no_full_solution_message = Message(i18n_catalog.i18nc("@info:status",
|
||||
"Unable to find a location within the build volume for all objects"),
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
no_full_solution_message.show()
|
||||
|
|
|
@ -1,23 +1,17 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import List
|
||||
|
||||
from UM.Application import Application
|
||||
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.Message import Message
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from UM.i18n import i18nCatalog
|
||||
from cura.Arranging.Nest2DArrange import arrange
|
||||
|
||||
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):
|
||||
def __init__(self, nodes: List[SceneNode], fixed_nodes: List[SceneNode], min_offset = 8) -> None:
|
||||
|
@ -29,79 +23,23 @@ class ArrangeObjectsJob(Job):
|
|||
def run(self):
|
||||
status_message = Message(i18n_catalog.i18nc("@info:status", "Finding new location for objects"),
|
||||
lifetime = 0,
|
||||
dismissable=False,
|
||||
dismissable = False,
|
||||
progress = 0,
|
||||
title = i18n_catalog.i18nc("@info:title", "Finding Location"))
|
||||
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)
|
||||
|
||||
# Build set to exclude children (those get arranged together with the parents).
|
||||
included_as_child = set()
|
||||
for node in self._nodes:
|
||||
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()
|
||||
found_solution_for_all = None
|
||||
try:
|
||||
found_solution_for_all = arrange(self._nodes, Application.getInstance().getBuildVolume(), self._fixed_nodes)
|
||||
except: # If the thread crashes, the message should still close
|
||||
Logger.logException("e", "Unable to arrange the objects on the buildplate. The arrange algorithm has crashed.")
|
||||
|
||||
status_message.hide()
|
||||
|
||||
if not found_solution_for_all:
|
||||
no_full_solution_message = Message(i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"),
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"))
|
||||
if found_solution_for_all is not None and not found_solution_for_all:
|
||||
no_full_solution_message = Message(
|
||||
i18n_catalog.i18nc("@info:status",
|
||||
"Unable to find a location within the build volume for all objects"),
|
||||
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
no_full_solution_message.show()
|
||||
|
||||
self.finished.emit(self)
|
||||
|
|
148
cura/Arranging/Nest2DArrange.py
Normal file
148
cura/Arranging/Nest2DArrange.py
Normal 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
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import numpy
|
||||
import copy
|
||||
from typing import Optional, Tuple, TYPE_CHECKING
|
||||
from typing import Optional, Tuple, TYPE_CHECKING, Union
|
||||
|
||||
from UM.Math.Polygon import Polygon
|
||||
|
||||
|
@ -11,19 +11,24 @@ if TYPE_CHECKING:
|
|||
from UM.Scene.SceneNode import SceneNode
|
||||
|
||||
|
||||
## Polygon representation as an array for use with Arrange
|
||||
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.offset_x = offset_x
|
||||
self.offset_y = offset_y
|
||||
self.scale = scale
|
||||
|
||||
## Instantiate from a bunch of vertices
|
||||
# \param vertices
|
||||
# \param scale scale the coordinates
|
||||
@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
|
||||
vertices = vertices * scale
|
||||
# flip y, x -> x, y
|
||||
|
@ -44,12 +49,16 @@ class ShapeArray:
|
|||
arr[0][0] = 1
|
||||
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
|
||||
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_x = transform._data[0][3]
|
||||
transform_y = transform._data[2][3]
|
||||
|
@ -88,15 +97,20 @@ class ShapeArray:
|
|||
|
||||
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
|
||||
def arrayFromPolygon(cls, shape: Tuple[int, int], vertices: numpy.array) -> numpy.array:
|
||||
base_array = numpy.zeros(shape, dtype = numpy.int32) # Initialize your array of zeros
|
||||
def arrayFromPolygon(cls, shape: Union[Tuple[int, int], numpy.ndarray], vertices: numpy.ndarray) -> numpy.ndarray:
|
||||
"""Create :py:class:`numpy.ndarray` with dimensions defined by shape
|
||||
|
||||
Fills polygon defined by vertices with ones, all other values zero
|
||||
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
|
||||
|
||||
|
@ -111,16 +125,21 @@ class ShapeArray:
|
|||
|
||||
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
|
||||
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]:
|
||||
return None
|
||||
idxs = numpy.indices(base_array.shape) # Create 3D array of indices
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from PyQt5.QtCore import QTimer
|
||||
|
@ -6,6 +6,8 @@ from typing import Any, TYPE_CHECKING
|
|||
|
||||
from UM.Logger import Logger
|
||||
|
||||
import time
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
|
@ -31,7 +33,6 @@ class AutoSave:
|
|||
self._change_timer.timeout.connect(self._onTimeout)
|
||||
self._application.globalContainerStackChanged.connect(self._onGlobalStackChanged)
|
||||
self._onGlobalStackChanged()
|
||||
self._triggerTimer()
|
||||
|
||||
def _triggerTimer(self, *args: Any) -> None:
|
||||
if not self._saving:
|
||||
|
@ -57,8 +58,8 @@ class AutoSave:
|
|||
|
||||
def _onTimeout(self) -> None:
|
||||
self._saving = True # To prevent the save process from triggering another autosave.
|
||||
Logger.log("d", "Autosaving preferences, instances and profiles")
|
||||
|
||||
save_start_time = time.time()
|
||||
self._application.saveSettings()
|
||||
|
||||
Logger.log("d", "Autosaving preferences, instances and profiles took %s seconds", time.time() - save_start_time)
|
||||
self._saving = False
|
||||
|
|
|
@ -5,42 +5,54 @@ import io
|
|||
import os
|
||||
import re
|
||||
import shutil
|
||||
from copy import deepcopy
|
||||
from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile
|
||||
from typing import Dict, Optional, TYPE_CHECKING
|
||||
from typing import Dict, Optional, TYPE_CHECKING, List
|
||||
|
||||
from UM import i18nCatalog
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.Platform import Platform
|
||||
from UM.Resources import Resources
|
||||
from UM.Version import Version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
|
||||
## 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:
|
||||
# These files should be ignored when making a backup.
|
||||
IGNORED_FILES = [r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc"]
|
||||
"""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.
|
||||
"""
|
||||
|
||||
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")
|
||||
"""Re-use translation catalog"""
|
||||
|
||||
def __init__(self, application: "CuraApplication", zip_file: bytes = None, meta_data: Dict[str, str] = None) -> None:
|
||||
self._application = application
|
||||
self.zip_file = zip_file # type: Optional[bytes]
|
||||
self.meta_data = meta_data # type: Optional[Dict[str, str]]
|
||||
|
||||
## Create a back-up from the current user config folder.
|
||||
def makeFromCurrent(self) -> None:
|
||||
"""Create a back-up from the current user config folder."""
|
||||
|
||||
cura_release = self._application.getVersion()
|
||||
version_data_dir = Resources.getDataStoragePath()
|
||||
|
||||
Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir)
|
||||
|
||||
# obfuscate sensitive secrets
|
||||
secrets = self._obfuscate()
|
||||
|
||||
# Ensure all current settings are saved.
|
||||
self._application.saveSettings()
|
||||
|
||||
|
@ -62,11 +74,12 @@ class Backup:
|
|||
files = archive.namelist()
|
||||
|
||||
# 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
|
||||
material_count = len([s for s in files if "materials/" in s]) - 1
|
||||
profile_count = len([s for s in files if "quality_changes/" in s]) - 1
|
||||
plugin_count = len([s for s in files if "plugin.json" in s])
|
||||
|
||||
machine_count = max(len([s for s in files if "machine_instances/" in s]) - 1, 0) # If people delete their profiles but not their preferences, it can still make a backup, and report -1 profiles. Server crashes on this.
|
||||
material_count = max(len([s for s in files if "materials/" in s]) - 1, 0)
|
||||
profile_count = max(len([s for s in files if "quality_changes/" in s]) - 1, 0)
|
||||
# We don't store plugins anymore, since if you can make backups, you have an account (and the plugins are
|
||||
# on the marketplace anyway)
|
||||
plugin_count = 0
|
||||
# Store the archive and metadata so the BackupManager can fetch them when needed.
|
||||
self.zip_file = buffer.getvalue()
|
||||
self.meta_data = {
|
||||
|
@ -76,12 +89,16 @@ class Backup:
|
|||
"profile_count": str(profile_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]:
|
||||
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:
|
||||
archive = ZipFile(buffer, "w", ZIP_DEFLATED)
|
||||
for root, folders, files in os.walk(root_path):
|
||||
|
@ -94,39 +111,55 @@ class Backup:
|
|||
return archive
|
||||
except (IOError, OSError, BadZipfile) as error:
|
||||
Logger.log("e", "Could not create archive from user data directory: %s", error)
|
||||
self._showMessage(
|
||||
self.catalog.i18nc("@info:backup_failed",
|
||||
"Could not create archive from user data directory: {}".format(error)))
|
||||
self._showMessage(self.catalog.i18nc("@info:backup_failed",
|
||||
"Could not create archive from user data directory: {}".format(error)),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
return None
|
||||
|
||||
## Show a UI message.
|
||||
def _showMessage(self, message: str) -> None:
|
||||
Message(message, title=self.catalog.i18nc("@info:title", "Backup"), lifetime=30).show()
|
||||
def _showMessage(self, message: str, message_type: Message.MessageType = Message.MessageType.NEUTRAL) -> None:
|
||||
"""Show a UI message."""
|
||||
|
||||
Message(message, title=self.catalog.i18nc("@info:title", "Backup"), message_type = message_type).show()
|
||||
|
||||
## Restore this back-up.
|
||||
# \return Whether we had success or not.
|
||||
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):
|
||||
# We can restore without the minimum required information.
|
||||
Logger.log("w", "Tried to restore a Cura backup without having proper data or meta data.")
|
||||
self._showMessage(
|
||||
self.catalog.i18nc("@info:backup_failed",
|
||||
"Tried to restore a Cura backup without having proper data or meta data."))
|
||||
self._showMessage(self.catalog.i18nc("@info:backup_failed",
|
||||
"Tried to restore a Cura backup without having proper data or meta data."),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
return False
|
||||
|
||||
current_version = self._application.getVersion()
|
||||
version_to_restore = self.meta_data.get("cura_release", "master")
|
||||
current_version = Version(self._application.getVersion())
|
||||
version_to_restore = Version(self.meta_data.get("cura_release", "master"))
|
||||
|
||||
if current_version < version_to_restore:
|
||||
# Cannot restore version newer than current because settings might have changed.
|
||||
Logger.log("d", "Tried to restore a Cura backup of version {version_to_restore} with cura version {current_version}".format(version_to_restore = version_to_restore, current_version = current_version))
|
||||
self._showMessage(
|
||||
self.catalog.i18nc("@info:backup_failed",
|
||||
"Tried to restore a Cura backup that is higher than the current version."))
|
||||
self._showMessage(self.catalog.i18nc("@info:backup_failed",
|
||||
"Tried to restore a Cura backup that is higher than the current version."),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
return False
|
||||
|
||||
# Get the current secrets and store since the back-up doesn't contain those
|
||||
secrets = self._obfuscate()
|
||||
|
||||
version_data_dir = Resources.getDataStoragePath()
|
||||
archive = ZipFile(io.BytesIO(self.zip_file), "r")
|
||||
try:
|
||||
archive = ZipFile(io.BytesIO(self.zip_file), "r")
|
||||
except LookupError as e:
|
||||
Logger.log("d", f"The following error occurred while trying to restore a Cura backup: {str(e)}")
|
||||
Message(self.catalog.i18nc("@info:backup_failed",
|
||||
"The following error occurred while trying to restore a Cura backup:") + str(e),
|
||||
title = self.catalog.i18nc("@info:title", "Backup"),
|
||||
message_type = Message.MessageType.ERROR).show()
|
||||
|
||||
return False
|
||||
extracted = self._extractArchive(archive, version_data_dir)
|
||||
|
||||
# Under Linux, preferences are stored elsewhere, so we copy the file to there.
|
||||
|
@ -137,14 +170,22 @@ class Backup:
|
|||
Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file)
|
||||
shutil.move(backup_preferences_file, preferences_file)
|
||||
|
||||
# Read the preferences from the newly restored configuration (or else the cached Preferences will override the restored ones)
|
||||
self._application.readPreferencesFromConfiguration()
|
||||
|
||||
# Restore the obfuscated settings
|
||||
self._illuminate(**secrets)
|
||||
|
||||
return extracted
|
||||
|
||||
## 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
|
||||
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.
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
@ -156,9 +197,36 @@ class Backup:
|
|||
Logger.log("d", "Removing current data in location: %s", target_path)
|
||||
Resources.factoryReset()
|
||||
Logger.log("d", "Extracting backup to location: %s", target_path)
|
||||
try:
|
||||
archive.extractall(target_path)
|
||||
except PermissionError:
|
||||
Logger.logException("e", "Unable to extract the backup due to permission errors")
|
||||
return False
|
||||
name_list = archive.namelist()
|
||||
for archive_filename in name_list:
|
||||
try:
|
||||
archive.extract(archive_filename, target_path)
|
||||
except (PermissionError, EnvironmentError):
|
||||
Logger.logException("e", f"Unable to extract the file {archive_filename} from the backup due to permission or file system errors.")
|
||||
CuraApplication.getInstance().processEvents()
|
||||
return True
|
||||
|
||||
def _obfuscate(self) -> Dict[str, str]:
|
||||
"""
|
||||
Obfuscate and remove the secret preferences that are specified in SECRETS_SETTINGS
|
||||
|
||||
:return: a dictionary of the removed secrets. Note: the '/' is replaced by '__'
|
||||
"""
|
||||
preferences = self._application.getPreferences()
|
||||
secrets = {}
|
||||
for secret in self.SECRETS_SETTINGS:
|
||||
secrets[secret.replace("/", "__")] = deepcopy(preferences.getValue(secret))
|
||||
preferences.setValue(secret, None)
|
||||
self._application.savePreferences()
|
||||
return secrets
|
||||
|
||||
def _illuminate(self, **kwargs) -> None:
|
||||
"""
|
||||
Restore the obfuscated settings
|
||||
|
||||
:param kwargs: a dict of obscured preferences. Note: the '__' of the keys will be replaced by '/'
|
||||
"""
|
||||
preferences = self._application.getPreferences()
|
||||
for key, value in kwargs.items():
|
||||
preferences.setValue(key.replace("__", "/"), value)
|
||||
self._application.savePreferences()
|
||||
|
|
|
@ -4,24 +4,31 @@
|
|||
from typing import Dict, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
from UM.Logger import Logger
|
||||
from UM.Version import Version
|
||||
from cura.Backups.Backup import Backup
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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:
|
||||
"""
|
||||
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:
|
||||
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]]]:
|
||||
"""
|
||||
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()
|
||||
backup = Backup(self._application)
|
||||
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.
|
||||
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:
|
||||
"""
|
||||
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 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.")
|
||||
|
@ -43,14 +53,16 @@ class BackupsManager:
|
|||
|
||||
backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data)
|
||||
restored = backup.restore()
|
||||
|
||||
if restored:
|
||||
# At this point, Cura will need to restart for the changes to take effect.
|
||||
# We don't want to store the data at this point as that would override the just-restored backup.
|
||||
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:
|
||||
"""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()
|
||||
# The auto save is only not created if the application has not yet started.
|
||||
if auto_save:
|
||||
|
@ -58,8 +70,10 @@ class BackupsManager:
|
|||
else:
|
||||
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:
|
||||
"""Re-enable auto-save and other saving after we're done."""
|
||||
|
||||
self._application.enableSave(True)
|
||||
auto_save = self._application.getAutoSave()
|
||||
# The auto save is only not created if the application has not yet started.
|
||||
if auto_save:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import numpy
|
||||
|
@ -44,8 +44,9 @@ catalog = i18nCatalog("cura")
|
|||
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):
|
||||
"""Build volume is a special kind of node that is responsible for rendering the printable area & disallowed areas."""
|
||||
|
||||
raftThicknessChanged = Signal()
|
||||
|
||||
def __init__(self, application: "CuraApplication", parent: Optional[SceneNode] = None) -> None:
|
||||
|
@ -91,10 +92,14 @@ class BuildVolume(SceneNode):
|
|||
self._adhesion_type = None # type: Any
|
||||
self._platform = Platform(self)
|
||||
|
||||
self._edge_disallowed_size = None
|
||||
|
||||
self._build_volume_message = Message(catalog.i18nc("@info:status",
|
||||
"The build volume height has been reduced due to the value of the"
|
||||
" \"Print Sequence\" setting to prevent the gantry from colliding"
|
||||
" with printed models."), title = catalog.i18nc("@info:title", "Build Volume"))
|
||||
"The build volume height has been reduced due to the value of the"
|
||||
" \"Print Sequence\" setting to prevent the gantry from colliding"
|
||||
" with printed models."),
|
||||
title = catalog.i18nc("@info:title", "Build Volume"),
|
||||
message_type = Message.MessageType.WARNING)
|
||||
|
||||
self._global_container_stack = None # type: Optional[GlobalStack]
|
||||
|
||||
|
@ -105,19 +110,17 @@ class BuildVolume(SceneNode):
|
|||
|
||||
self._application.globalContainerStackChanged.connect(self._onStackChanged)
|
||||
|
||||
self._onStackChanged()
|
||||
|
||||
self._engine_ready = False
|
||||
self._application.engineCreatedSignal.connect(self._onEngineCreated)
|
||||
|
||||
self._has_errors = False
|
||||
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_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.timeout.connect(self._onSceneChangeTimerFinished)
|
||||
|
||||
|
@ -163,10 +166,12 @@ class BuildVolume(SceneNode):
|
|||
self._scene_objects = new_scene_objects
|
||||
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):
|
||||
"""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")
|
||||
if per_mesh_stack:
|
||||
per_mesh_stack.propertyChanged.connect(self._onSettingPropertyChanged)
|
||||
|
@ -177,20 +182,33 @@ class BuildVolume(SceneNode):
|
|||
def setWidth(self, width: float) -> None:
|
||||
self._width = width
|
||||
|
||||
def getWidth(self) -> float:
|
||||
return self._width
|
||||
|
||||
def setHeight(self, height: float) -> None:
|
||||
self._height = height
|
||||
|
||||
def getHeight(self) -> float:
|
||||
return self._height
|
||||
|
||||
def setDepth(self, depth: float) -> None:
|
||||
self._depth = depth
|
||||
|
||||
def getDepth(self) -> float:
|
||||
return self._depth
|
||||
|
||||
def setShape(self, shape: str) -> None:
|
||||
if 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:
|
||||
"""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)
|
||||
|
||||
def getDisallowedAreas(self) -> List[Polygon]:
|
||||
|
@ -226,9 +244,9 @@ class BuildVolume(SceneNode):
|
|||
|
||||
return True
|
||||
|
||||
## For every sliceable node, update node._outside_buildarea
|
||||
#
|
||||
def updateNodeBoundaryCheck(self):
|
||||
"""For every sliceable node, update node._outside_buildarea"""
|
||||
|
||||
if not self._global_container_stack:
|
||||
return
|
||||
|
||||
|
@ -265,7 +283,7 @@ class BuildVolume(SceneNode):
|
|||
continue
|
||||
# If the entire node is below the build plate, still mark it as outside.
|
||||
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)
|
||||
continue
|
||||
# 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:
|
||||
node.setOutsideBuildArea(True)
|
||||
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
|
||||
|
||||
node.setOutsideBuildArea(False)
|
||||
|
@ -293,8 +313,13 @@ class BuildVolume(SceneNode):
|
|||
for child_node in children:
|
||||
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:
|
||||
"""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:
|
||||
return
|
||||
|
||||
|
@ -321,7 +346,12 @@ class BuildVolume(SceneNode):
|
|||
|
||||
# Mark the node as outside build volume if the set extruder is disabled
|
||||
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)
|
||||
return
|
||||
|
||||
|
@ -482,8 +512,9 @@ class BuildVolume(SceneNode):
|
|||
self._disallowed_area_size = max(size, self._disallowed_area_size)
|
||||
return mb.build()
|
||||
|
||||
## Recalculates the build volume & disallowed areas.
|
||||
def rebuild(self) -> None:
|
||||
"""Recalculates the build volume & disallowed areas."""
|
||||
|
||||
if not self._width or not self._height or not self._depth:
|
||||
return
|
||||
|
||||
|
@ -572,7 +603,7 @@ class BuildVolume(SceneNode):
|
|||
def _calculateExtraZClearance(self, extruders: List["ContainerStack"]) -> float:
|
||||
if not self._global_container_stack:
|
||||
return 0
|
||||
|
||||
|
||||
extra_z = 0.0
|
||||
for extruder in extruders:
|
||||
if extruder.getProperty("retraction_hop_enabled", "value"):
|
||||
|
@ -584,8 +615,9 @@ class BuildVolume(SceneNode):
|
|||
def _onStackChanged(self):
|
||||
self._stack_change_timer.start()
|
||||
|
||||
## Update the build volume visualization
|
||||
def _onStackChangeTimerFinished(self) -> None:
|
||||
"""Update the build volume visualization"""
|
||||
|
||||
if self._global_container_stack:
|
||||
self._global_container_stack.propertyChanged.disconnect(self._onSettingPropertyChanged)
|
||||
extruders = ExtruderManager.getInstance().getActiveExtruderStacks()
|
||||
|
@ -710,15 +742,15 @@ class BuildVolume(SceneNode):
|
|||
self._depth = self._global_container_stack.getProperty("machine_depth", "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):
|
||||
"""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._updateRaftThickness()
|
||||
self._extra_z_clearance = self._calculateExtraZClearance(ExtruderManager.getInstance().getUsedExtruderStacks())
|
||||
|
@ -731,6 +763,7 @@ class BuildVolume(SceneNode):
|
|||
self._error_areas = []
|
||||
|
||||
used_extruders = ExtruderManager.getInstance().getUsedExtruderStacks()
|
||||
self._edge_disallowed_size = None # Force a recalculation
|
||||
disallowed_border_size = self.getEdgeDisallowedSize()
|
||||
|
||||
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.
|
||||
break
|
||||
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:
|
||||
result_areas[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:
|
||||
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):
|
||||
"""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 = {}
|
||||
adhesion_extruder = None #type: ExtruderStack
|
||||
for extruder in used_extruders:
|
||||
|
@ -817,7 +852,7 @@ class BuildVolume(SceneNode):
|
|||
prime_tower_y += brim_size
|
||||
|
||||
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.getMinkowskiHull(Polygon.approximatedCircle(0))
|
||||
|
@ -826,18 +861,18 @@ class BuildVolume(SceneNode):
|
|||
|
||||
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]]:
|
||||
"""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]]
|
||||
if not self._global_container_stack:
|
||||
return result
|
||||
|
@ -865,25 +900,26 @@ class BuildVolume(SceneNode):
|
|||
|
||||
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]]:
|
||||
"""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.
|
||||
machine_disallowed_polygons = []
|
||||
if self._global_container_stack is None:
|
||||
return {}
|
||||
|
||||
for area in self._global_container_stack.getProperty("machine_disallowed_areas", "value"):
|
||||
if len(area) == 0:
|
||||
continue # Numpy doesn't deal well with 0-length arrays, since it can't determine the dimensionality of them.
|
||||
polygon = Polygon(numpy.array(area, numpy.float32))
|
||||
polygon = polygon.getMinkowskiHull(Polygon.approximatedCircle(border_size))
|
||||
machine_disallowed_polygons.append(polygon)
|
||||
|
@ -1008,13 +1044,14 @@ class BuildVolume(SceneNode):
|
|||
|
||||
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]:
|
||||
"""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_types = ExtruderManager.getInstance().getAllExtruderSettings(setting_key, "type")
|
||||
for i, (setting_value, setting_type) in enumerate(zip(all_values, all_types)):
|
||||
|
@ -1022,16 +1059,30 @@ class BuildVolume(SceneNode):
|
|||
all_values[i] = 0
|
||||
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:
|
||||
return None
|
||||
|
||||
container_stack = self._global_container_stack
|
||||
adhesion_type = container_stack.getProperty("adhesion_type", "value")
|
||||
skirt_brim_line_width = self._global_container_stack.getProperty("skirt_brim_line_width", "value")
|
||||
adhesion_type = adhesion_override
|
||||
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")
|
||||
# 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")
|
||||
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.
|
||||
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_line_count = self._global_container_stack.getProperty("skirt_line_count", "value")
|
||||
|
||||
|
@ -1084,25 +1135,34 @@ class BuildVolume(SceneNode):
|
|||
|
||||
def _calculateMoveFromWallRadius(self, used_extruders):
|
||||
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"))
|
||||
move_from_wall_radius = max(all_values)
|
||||
avoid_enabled_per_extruder = [stack.getProperty("travel_avoid_other_parts", "value") for stack in used_extruders]
|
||||
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).
|
||||
if avoid_other_parts_enabled:
|
||||
move_from_wall_radius = max(move_from_wall_radius, avoid_distance)
|
||||
|
||||
for stack in used_extruders:
|
||||
if stack.getProperty("travel_avoid_other_parts", "value"):
|
||||
move_from_wall_radius = max(move_from_wall_radius, stack.getProperty("travel_avoid_distance", "value"))
|
||||
|
||||
infill_wipe_distance = stack.getProperty("infill_wipe_dist", "value")
|
||||
num_walls = stack.getProperty("wall_line_count", "value")
|
||||
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
|
||||
|
||||
## 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):
|
||||
"""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:
|
||||
return 0
|
||||
|
||||
if self._edge_disallowed_size is not None:
|
||||
return self._edge_disallowed_size
|
||||
|
||||
container_stack = self._global_container_stack
|
||||
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.
|
||||
# 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.
|
||||
border_size = max(move_from_wall_radius, support_expansion + farthest_shield_distance, support_expansion + bed_adhesion_size)
|
||||
return border_size
|
||||
self._edge_disallowed_size = max(move_from_wall_radius, support_expansion + farthest_shield_distance, support_expansion + bed_adhesion_size)
|
||||
return self._edge_disallowed_size
|
||||
|
||||
def _clamp(self, value, min_value, max_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"]
|
||||
_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"]
|
||||
_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"]
|
||||
_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.
|
||||
_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
|
||||
|
|
|
@ -88,7 +88,7 @@ class CrashHandler:
|
|||
@staticmethod
|
||||
def pruneSensitiveData(obj: Any) -> Any:
|
||||
if isinstance(obj, str):
|
||||
return obj.replace(home_dir, "<user_home>")
|
||||
return obj.replace("\\\\", "\\").replace(home_dir, "<user_home>")
|
||||
if isinstance(obj, list):
|
||||
return [CrashHandler.pruneSensitiveData(item) for item in obj]
|
||||
if isinstance(obj, dict):
|
||||
|
@ -150,8 +150,9 @@ class CrashHandler:
|
|||
self._sendCrashReport()
|
||||
os._exit(1)
|
||||
|
||||
## Backup the current resource directories and create clean ones.
|
||||
def _backupAndStartClean(self):
|
||||
"""Backup the current resource directories and create clean ones."""
|
||||
|
||||
Resources.factoryReset()
|
||||
self.early_crash_dialog.close()
|
||||
|
||||
|
@ -162,8 +163,9 @@ class CrashHandler:
|
|||
def _showDetailedReport(self):
|
||||
self.dialog.exec_()
|
||||
|
||||
## Creates a modal dialog.
|
||||
def _createDialog(self):
|
||||
"""Creates a modal dialog."""
|
||||
|
||||
self.dialog.setMinimumWidth(640)
|
||||
self.dialog.setMinimumHeight(640)
|
||||
self.dialog.setWindowTitle(catalog.i18nc("@title:window", "Crash Report"))
|
||||
|
@ -213,6 +215,16 @@ class CrashHandler:
|
|||
locale.getdefaultlocale()[0]
|
||||
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 language") + ":</b> " + str(self.cura_locale) + "<br/>"
|
||||
crash_info += "<b>" + catalog.i18nc("@label", "OS language") + ":</b> " + str(self.data["locale_os"]) + "<br/>"
|
||||
|
@ -235,8 +247,13 @@ class CrashHandler:
|
|||
scope.set_tag("locale_os", self.data["locale_os"])
|
||||
scope.set_tag("locale_cura", self.cura_locale)
|
||||
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
|
||||
|
||||
|
|
|
@ -40,12 +40,13 @@ class CuraActions(QObject):
|
|||
|
||||
@pyqtSlot()
|
||||
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)
|
||||
|
||||
## Reset camera position and direction to default
|
||||
@pyqtSlot()
|
||||
def homeCamera(self) -> None:
|
||||
"""Reset camera position and direction to default"""
|
||||
|
||||
scene = cura.CuraApplication.CuraApplication.getInstance().getController().getScene()
|
||||
camera = scene.getActiveCamera()
|
||||
if camera:
|
||||
|
@ -54,9 +55,10 @@ class CuraActions(QObject):
|
|||
camera.setPerspective(True)
|
||||
camera.lookAt(Vector(0, 0, 0))
|
||||
|
||||
## Center all objects in the selection
|
||||
@pyqtSlot()
|
||||
def centerSelection(self) -> None:
|
||||
"""Center all objects in the selection"""
|
||||
|
||||
operation = GroupedOperation()
|
||||
for node in Selection.getAllSelectedObjects():
|
||||
current_node = node
|
||||
|
@ -65,26 +67,33 @@ class CuraActions(QObject):
|
|||
current_node = parent_node
|
||||
parent_node = current_node.getParent()
|
||||
|
||||
# This was formerly done with SetTransformOperation but because of
|
||||
# unpredictable matrix deconstruction it was possible that mirrors
|
||||
# could manifest as rotations. Centering is therefore done by
|
||||
# moving the node to negative whatever its position is:
|
||||
center_operation = TranslateOperation(current_node, -current_node._position)
|
||||
# Find out where the bottom of the object is
|
||||
bbox = current_node.getBoundingBox()
|
||||
if bbox:
|
||||
center_y = current_node.getWorldPosition().y - bbox.bottom
|
||||
else:
|
||||
center_y = 0
|
||||
|
||||
# Move the object so that it's bottom is on to of the buildplate
|
||||
center_operation = TranslateOperation(current_node, Vector(0, center_y, 0), set_position = True)
|
||||
operation.addOperation(center_operation)
|
||||
operation.push()
|
||||
|
||||
## Multiply all objects in the selection
|
||||
#
|
||||
# \param count The number of times to multiply the selection.
|
||||
@pyqtSlot(int)
|
||||
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
|
||||
job = MultiplyObjectsJob(Selection.getAllSelectedObjects(), count, min_offset = max(min_offset, 8))
|
||||
job.start()
|
||||
|
||||
## Delete all selected objects.
|
||||
@pyqtSlot()
|
||||
def deleteSelection(self) -> None:
|
||||
"""Delete all selected objects."""
|
||||
|
||||
if not cura.CuraApplication.CuraApplication.getInstance().getController().getToolsEnabled():
|
||||
return
|
||||
|
||||
|
@ -106,11 +115,13 @@ class CuraActions(QObject):
|
|||
|
||||
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)
|
||||
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()
|
||||
|
||||
nodes_to_change = []
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -24,11 +24,15 @@ class CuraPackageManager(PackageManager):
|
|||
|
||||
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]]]:
|
||||
"""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)
|
||||
container_stacks = self._application.getContainerRegistry().findContainerStacks()
|
||||
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 = []
|
||||
for container_id in ids:
|
||||
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")):
|
||||
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():
|
||||
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
|
||||
|
|
|
@ -9,4 +9,5 @@ CuraDebugMode = True if "@_cura_debugmode@" == "ON" else False
|
|||
CuraCloudAPIRoot = "@CURA_CLOUD_API_ROOT@"
|
||||
CuraCloudAPIVersion = "@CURA_CLOUD_API_VERSION@"
|
||||
CuraCloudAccountAPIRoot = "@CURA_CLOUD_ACCOUNT_API_ROOT@"
|
||||
CuraMarketplaceRoot = "@CURA_MARKETPLACE_ROOT@"
|
||||
CuraMarketplaceRoot = "@CURA_MARKETPLACE_ROOT@"
|
||||
CuraDigitalFactoryURL = "@CURA_DIGITAL_FACTORY_URL@"
|
||||
|
|
|
@ -76,7 +76,7 @@ class Layer:
|
|||
|
||||
def createMeshOrJumps(self, make_mesh: bool) -> MeshData:
|
||||
builder = MeshBuilder()
|
||||
|
||||
|
||||
line_count = 0
|
||||
if make_mesh:
|
||||
for polygon in self._polygons:
|
||||
|
@ -87,7 +87,7 @@ class Layer:
|
|||
|
||||
# Reserve the necessary space for the data upfront
|
||||
builder.reserveFaceAndVertexCount(2 * line_count, 4 * line_count)
|
||||
|
||||
|
||||
for polygon in self._polygons:
|
||||
# Filter out the types of lines we are not interested in depending on whether we are drawing the mesh or the jumps.
|
||||
index_mask = numpy.logical_not(polygon.jumpMask) if make_mesh else polygon.jumpMask
|
||||
|
@ -96,7 +96,7 @@ class Layer:
|
|||
points = numpy.concatenate((polygon.data[:-1], polygon.data[1:]), 1)[index_mask.ravel()]
|
||||
# Line types of the points we want to draw
|
||||
line_types = polygon.types[index_mask]
|
||||
|
||||
|
||||
# Shift the z-axis according to previous implementation.
|
||||
if make_mesh:
|
||||
points[polygon.isInfillOrSkinType(line_types), 1::3] -= 0.01
|
||||
|
@ -118,5 +118,5 @@ class Layer:
|
|||
f_colors = numpy.repeat(polygon.mapLineTypeToColor(line_types), 4, 0)
|
||||
|
||||
builder.addFacesWithColor(f_points, f_indices, f_colors)
|
||||
|
||||
|
||||
return builder.build()
|
|
@ -3,9 +3,12 @@
|
|||
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 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,
|
||||
center_position = None, layers=None, element_counts=None, attributes=None):
|
||||
super().__init__(vertices=vertices, normals=normals, indices=indices, colors=colors, uvs=uvs,
|
||||
|
|
|
@ -10,8 +10,9 @@ import numpy
|
|||
from typing import Dict, Optional
|
||||
|
||||
|
||||
## Builder class for constructing a LayerData object
|
||||
class LayerDataBuilder(MeshBuilder):
|
||||
"""Builder class for constructing a :py:class:`cura.LayerData.LayerData` object"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._layers = {} # type: Dict[int, Layer]
|
||||
|
@ -42,11 +43,13 @@ class LayerDataBuilder(MeshBuilder):
|
|||
|
||||
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):
|
||||
"""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
|
||||
index_count = 0
|
||||
for layer, data in self._layers.items():
|
||||
|
|
|
@ -7,8 +7,9 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
|
|||
from cura.LayerData import LayerData
|
||||
|
||||
|
||||
## Simple decorator to indicate a scene node holds layer data.
|
||||
class LayerDataDecorator(SceneNodeDecorator):
|
||||
"""Simple decorator to indicate a scene node holds layer data."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._layer_data = None # type: Optional[LayerData]
|
||||
|
|
|
@ -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)
|
||||
|
||||
## 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:
|
||||
"""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._types = line_types
|
||||
for i in range(len(self._types)):
|
||||
|
@ -59,43 +62,45 @@ class LayerPolygon:
|
|||
# re-used and can save alot of memory usage.
|
||||
self._color_map = LayerPolygon.getColorMap()
|
||||
self._colors = self._color_map[self._types] # type: numpy.ndarray
|
||||
|
||||
|
||||
# When type is used as index returns true if type == LayerPolygon.InfillType or type == LayerPolygon.SkinType or type == LayerPolygon.SupportInfillType
|
||||
# Should be generated in better way, not hardcoded.
|
||||
self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = numpy.bool)
|
||||
|
||||
self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = bool)
|
||||
|
||||
self._build_cache_line_mesh_mask = None # type: Optional[numpy.ndarray]
|
||||
self._build_cache_needed_points = None # type: Optional[numpy.ndarray]
|
||||
|
||||
|
||||
def buildCache(self) -> None:
|
||||
# For the line mesh we do not draw Infill or Jumps. Therefore those lines are filtered out.
|
||||
self._build_cache_line_mesh_mask = numpy.ones(self._jump_mask.shape, dtype = bool)
|
||||
mesh_line_count = numpy.sum(self._build_cache_line_mesh_mask)
|
||||
self._index_begin = 0
|
||||
self._index_end = mesh_line_count
|
||||
|
||||
self._build_cache_needed_points = numpy.ones((len(self._types), 2), dtype = numpy.bool)
|
||||
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 = bool)
|
||||
# Only if the type of line segment changes do we need to add an extra vertex to change colors
|
||||
self._build_cache_needed_points[1:, 0][:, numpy.newaxis] = self._types[1:] != self._types[:-1]
|
||||
# Mark points as unneeded if they are of types we don't want in the line mesh according to the calculated mask
|
||||
numpy.logical_and(self._build_cache_needed_points, self._build_cache_line_mesh_mask, self._build_cache_needed_points )
|
||||
|
||||
self._vertex_begin = 0
|
||||
self._vertex_end = numpy.sum( self._build_cache_needed_points )
|
||||
self._vertex_end = cast(int, numpy.sum(self._build_cache_needed_points))
|
||||
|
||||
## 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:
|
||||
"""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:
|
||||
self.buildCache()
|
||||
|
||||
|
@ -105,16 +110,16 @@ class LayerPolygon:
|
|||
|
||||
line_mesh_mask = self._build_cache_line_mesh_mask
|
||||
needed_points_list = self._build_cache_needed_points
|
||||
|
||||
|
||||
# Index to the points we need to represent the line mesh. This is constructed by generating simple
|
||||
# start and end points for each line. For line segment n these are points n and n+1. Row n reads [n n+1]
|
||||
# Then then the indices for the points we don't need are thrown away based on the pre-calculated list.
|
||||
index_list = ( numpy.arange(len(self._types)).reshape((-1, 1)) + numpy.array([[0, 1]]) ).reshape((-1, 1))[needed_points_list.reshape((-1, 1))]
|
||||
|
||||
|
||||
# The relative values of begin and end indices have already been set in buildCache, so we only need to offset them to the parents offset.
|
||||
self._vertex_begin += vertex_offset
|
||||
self._vertex_end += vertex_offset
|
||||
|
||||
|
||||
# Points are picked based on the index list to get the vertices needed.
|
||||
vertices[self._vertex_begin:self._vertex_end, :] = self._data[index_list, :]
|
||||
|
||||
|
@ -136,14 +141,14 @@ class LayerPolygon:
|
|||
# The relative values of begin and end indices have already been set in buildCache, so we only need to offset them to the parents offset.
|
||||
self._index_begin += index_offset
|
||||
self._index_end += index_offset
|
||||
|
||||
|
||||
indices[self._index_begin:self._index_end, :] = numpy.arange(self._index_end-self._index_begin, dtype = numpy.int32).reshape((-1, 1))
|
||||
# When the line type changes the index needs to be increased by 2.
|
||||
indices[self._index_begin:self._index_end, :] += numpy.cumsum(needed_points_list[line_mesh_mask.ravel(), 0], dtype = numpy.int32).reshape((-1, 1))
|
||||
# Each line segment goes from it's starting point p to p+1, offset by the vertex index.
|
||||
# The -1 is to compensate for the neccecarily True value of needed_points_list[0,0] which causes an unwanted +1 in cumsum above.
|
||||
indices[self._index_begin:self._index_end, :] += numpy.array([self._vertex_begin - 1, self._vertex_begin])
|
||||
|
||||
|
||||
self._build_cache_line_mesh_mask = None
|
||||
self._build_cache_needed_points = None
|
||||
|
||||
|
@ -189,7 +194,7 @@ class LayerPolygon:
|
|||
@property
|
||||
def lineFeedrates(self):
|
||||
return self._line_feedrates
|
||||
|
||||
|
||||
@property
|
||||
def jumpMask(self):
|
||||
return self._jump_mask
|
||||
|
@ -202,8 +207,12 @@ class LayerPolygon:
|
|||
def jumpCount(self):
|
||||
return self._jump_count
|
||||
|
||||
# Calculate normals for the entire polygon using numpy.
|
||||
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[:, 1] = 0.0 # We are only interested in 2D normals
|
||||
|
||||
|
@ -229,9 +238,10 @@ class LayerPolygon:
|
|||
|
||||
__color_map = None # type: numpy.ndarray
|
||||
|
||||
## Gets the instance of the VersionUpgradeManager, or creates one.
|
||||
@classmethod
|
||||
def getColorMap(cls) -> numpy.ndarray:
|
||||
"""Gets the instance of the VersionUpgradeManager, or creates one."""
|
||||
|
||||
if cls.__color_map is None:
|
||||
theme = cast(Theme, QtApplication.getInstance().getTheme())
|
||||
cls.__color_map = numpy.array([
|
||||
|
|
|
@ -11,16 +11,22 @@ from UM.PluginObject import PluginObject
|
|||
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):
|
||||
"""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:
|
||||
"""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__()
|
||||
self._key = key
|
||||
self._label = label
|
||||
|
@ -34,10 +40,14 @@ class MachineAction(QObject, PluginObject):
|
|||
def getKey(self) -> str:
|
||||
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:
|
||||
"""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
|
||||
|
||||
@pyqtProperty(str, notify = labelChanged)
|
||||
|
@ -49,17 +59,24 @@ class MachineAction(QObject, PluginObject):
|
|||
self._label = label
|
||||
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()
|
||||
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._reset()
|
||||
|
||||
## Protected implementation of reset.
|
||||
# /sa reset()
|
||||
def _reset(self) -> None:
|
||||
"""Protected implementation of reset.
|
||||
|
||||
See also :py:meth:`cura.MachineAction.MachineAction.reset`
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@pyqtSlot()
|
||||
|
@ -72,8 +89,9 @@ class MachineAction(QObject, PluginObject):
|
|||
def finished(self) -> bool:
|
||||
return self._finished
|
||||
|
||||
## Protected helper to create a view object based on provided QML.
|
||||
def _createViewFromQML(self) -> Optional["QObject"]:
|
||||
"""Protected helper to create a view object based on provided QML."""
|
||||
|
||||
plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId())
|
||||
if plugin_path is None:
|
||||
Logger.log("e", "Cannot create QML view: cannot find plugin path for plugin [%s]", self.getPluginId())
|
||||
|
|
|
@ -9,47 +9,59 @@ from UM.Logger import Logger
|
|||
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:
|
||||
## Creates a new node for the container tree.
|
||||
# \param container_id The ID of the container that this node should
|
||||
# represent.
|
||||
"""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.
|
||||
"""
|
||||
|
||||
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 = None # type: Optional[InstanceContainer]
|
||||
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]:
|
||||
"""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]
|
||||
|
||||
## 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:
|
||||
"""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)
|
||||
if len(container_metadata) == 0:
|
||||
return 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
|
||||
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:
|
||||
container_list = ContainerRegistry.getInstance().findInstanceContainers(id = self.container_id)
|
||||
if len(container_list) == 0:
|
||||
|
|
|
@ -19,17 +19,16 @@ if TYPE_CHECKING:
|
|||
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:
|
||||
"""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"]
|
||||
|
||||
@classmethod
|
||||
|
@ -43,13 +42,15 @@ class ContainerTree:
|
|||
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.
|
||||
|
||||
## 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"]:
|
||||
"""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()
|
||||
if global_stack is None:
|
||||
return {}
|
||||
|
@ -58,14 +59,15 @@ class ContainerTree:
|
|||
extruder_enabled = [extruder.isEnabled for extruder in global_stack.extruderList]
|
||||
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"]:
|
||||
"""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()
|
||||
if global_stack is None:
|
||||
return []
|
||||
|
@ -74,31 +76,43 @@ class ContainerTree:
|
|||
extruder_enabled = [extruder.isEnabled for extruder in global_stack.extruderList]
|
||||
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:
|
||||
"""Ran after completely starting up the application."""
|
||||
|
||||
currently_added = ContainerRegistry.getInstance().findContainerStacks() # Find all currently added global stacks.
|
||||
JobQueue.getInstance().add(self._MachineNodeLoadJob(self, currently_added))
|
||||
|
||||
## Dictionary-like object that contains the machines.
|
||||
#
|
||||
# This handles the lazy loading of MachineNodes.
|
||||
class _MachineNodeMap:
|
||||
"""Dictionary-like object that contains the machines.
|
||||
|
||||
This handles the lazy loading of MachineNodes.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
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:
|
||||
"""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
|
||||
|
||||
## 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:
|
||||
"""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:
|
||||
start_time = time.time()
|
||||
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))
|
||||
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]:
|
||||
"""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:
|
||||
return default
|
||||
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:
|
||||
"""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
|
||||
|
||||
## Pre-loads all currently added printers as a background task so that
|
||||
# switching printers in the interface is faster.
|
||||
class _MachineNodeLoadJob(Job):
|
||||
## 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.
|
||||
"""Pre-loads all currently added printers as a background task so that switching printers in the interface is
|
||||
faster.
|
||||
"""
|
||||
|
||||
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.container_stacks = container_stacks
|
||||
super().__init__()
|
||||
|
||||
## Starts the background task.
|
||||
#
|
||||
# The ``JobQueue`` will schedule this on a different thread.
|
||||
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.
|
||||
if not isinstance(stack, GlobalStack):
|
||||
continue
|
||||
|
@ -156,3 +182,4 @@ class ContainerTree:
|
|||
definition_id = stack.definition.getId()
|
||||
if not self.tree_root.machines.is_loaded(definition_id):
|
||||
_ = self.tree_root.machines[definition_id]
|
||||
Logger.log("d", "All MachineNode loading completed")
|
|
@ -11,10 +11,12 @@ if TYPE_CHECKING:
|
|||
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):
|
||||
"""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:
|
||||
super().__init__(container_id)
|
||||
self.quality = quality
|
||||
|
|
|
@ -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.
|
||||
|
||||
import time
|
||||
|
@ -13,16 +13,17 @@ from UM.Settings.SettingDefinition import SettingDefinition
|
|||
from UM.Settings.Validator import ValidatorState
|
||||
|
||||
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):
|
||||
"""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:
|
||||
super().__init__(parent)
|
||||
|
@ -50,6 +51,8 @@ class MachineErrorChecker(QObject):
|
|||
self._error_check_timer.setInterval(100)
|
||||
self._error_check_timer.setSingleShot(True)
|
||||
|
||||
self._keys_to_check = set() # type: Set[str]
|
||||
|
||||
def initialize(self) -> None:
|
||||
self._error_check_timer.timeout.connect(self._rescheduleCheck)
|
||||
|
||||
|
@ -92,24 +95,38 @@ class MachineErrorChecker(QObject):
|
|||
def needToWaitForResult(self) -> bool:
|
||||
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:
|
||||
"""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":
|
||||
return
|
||||
self._keys_to_check.add(key)
|
||||
self.startErrorCheck()
|
||||
|
||||
# Starts the error check timer to schedule a new error check.
|
||||
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:
|
||||
self._need_to_check = True
|
||||
self.needToWaitForResultChanged.emit()
|
||||
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:
|
||||
"""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:
|
||||
self._need_to_check = True
|
||||
self.needToWaitForResultChanged.emit()
|
||||
|
@ -127,7 +144,10 @@ class MachineErrorChecker(QObject):
|
|||
# Populate the (stack, key) tuples to check
|
||||
self._stacks_and_keys_to_check = deque()
|
||||
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._application.callLater(self._checkStack)
|
||||
|
@ -168,18 +188,25 @@ class MachineErrorChecker(QObject):
|
|||
validator = validator_type(key)
|
||||
validation_state = validator(stack)
|
||||
if validation_state in (ValidatorState.Exception, ValidatorState.MaximumError, ValidatorState.MinimumError, ValidatorState.Invalid):
|
||||
# Finish
|
||||
self._setResult(True)
|
||||
# Since we don't know if any of the settings we didn't check is has an error value, store the list for the
|
||||
# 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
|
||||
|
||||
# Schedule the check for the next key
|
||||
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:
|
||||
self._has_errors = result
|
||||
self.hasErrorUpdated.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._check_in_progress = False
|
||||
self.needToWaitForResultChanged.emit()
|
||||
|
|
|
@ -17,10 +17,12 @@ from cura.Machines.VariantNode import VariantNode
|
|||
import UM.FlameProfiler
|
||||
|
||||
|
||||
## This class represents a machine in the container tree.
|
||||
#
|
||||
# The subnodes of these nodes are variants.
|
||||
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:
|
||||
super().__init__(container_id)
|
||||
self.variants = {} # type: Dict[str, VariantNode] # Mapping variant names to their nodes.
|
||||
|
@ -47,20 +49,21 @@ class MachineNode(ContainerNode):
|
|||
|
||||
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]:
|
||||
"""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):
|
||||
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 {}
|
||||
|
@ -98,28 +101,26 @@ class MachineNode(ContainerNode):
|
|||
quality_groups[quality_type].is_available = True
|
||||
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]:
|
||||
"""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.
|
||||
|
||||
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"],
|
||||
intent_category = quality_changes.get("intent_category", "default"),
|
||||
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.
|
||||
groups_by_name[name].intent_category = quality_changes.get("intent_category", "default")
|
||||
|
||||
|
@ -145,20 +144,33 @@ class MachineNode(ContainerNode):
|
|||
else: # Global profile.
|
||||
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())
|
||||
|
||||
## 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":
|
||||
"""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())))
|
||||
|
||||
## (Re)loads all variants under this printer.
|
||||
@UM.FlameProfiler.profile
|
||||
def _loadAll(self) -> None:
|
||||
"""(Re)loads all variants under this printer."""
|
||||
|
||||
container_registry = ContainerRegistry.getInstance()
|
||||
if not self.has_variants:
|
||||
self.variants["empty"] = VariantNode("empty_variant", machine = self)
|
||||
|
@ -171,6 +183,10 @@ class MachineNode(ContainerNode):
|
|||
if variant_name not in self.variants:
|
||||
self.variants[variant_name] = VariantNode(variant["id"], machine = self)
|
||||
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:
|
||||
self.variants["empty"] = VariantNode("empty_variant", machine = self)
|
||||
|
||||
|
|
|
@ -7,18 +7,21 @@ if TYPE_CHECKING:
|
|||
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:
|
||||
"""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")
|
||||
|
||||
def __init__(self, name: str, root_material_node: "MaterialNode") -> None:
|
||||
|
|
|
@ -15,10 +15,12 @@ if TYPE_CHECKING:
|
|||
from cura.Machines.VariantNode import VariantNode
|
||||
|
||||
|
||||
## Represents a material in the container tree.
|
||||
#
|
||||
# Its subcontainers are quality profiles.
|
||||
class MaterialNode(ContainerNode):
|
||||
"""Represents a material in the container tree.
|
||||
|
||||
Its subcontainers are quality profiles.
|
||||
"""
|
||||
|
||||
def __init__(self, container_id: str, variant: "VariantNode") -> None:
|
||||
super().__init__(container_id)
|
||||
self.variant = variant
|
||||
|
@ -34,16 +36,16 @@ class MaterialNode(ContainerNode):
|
|||
container_registry.containerRemoved.connect(self._onRemoved)
|
||||
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:
|
||||
"""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():
|
||||
if self.variant.machine.preferred_quality_type == quality_node.quality_type:
|
||||
return quality_node
|
||||
|
@ -86,8 +88,10 @@ class MaterialNode(ContainerNode):
|
|||
variant = self.variant.variant_name)
|
||||
else:
|
||||
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.
|
||||
my_guid = self.guid
|
||||
|
@ -107,10 +111,13 @@ class MaterialNode(ContainerNode):
|
|||
if not self.qualities:
|
||||
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:
|
||||
"""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:
|
||||
# Remove myself from my parent.
|
||||
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.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:
|
||||
"""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:
|
||||
return
|
||||
|
||||
|
|
|
@ -13,11 +13,13 @@ from cura.Machines.ContainerTree import ContainerTree
|
|||
from cura.Machines.MaterialNode import MaterialNode
|
||||
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):
|
||||
"""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()
|
||||
enabledChanged = pyqtSignal()
|
||||
|
@ -121,10 +123,13 @@ class BaseMaterialsModel(ListModel):
|
|||
def enabled(self):
|
||||
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:
|
||||
"""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:
|
||||
return
|
||||
if material.variant.container_id != self._extruder_stack.variant.getId():
|
||||
|
@ -136,23 +141,25 @@ class BaseMaterialsModel(ListModel):
|
|||
return
|
||||
self._onChanged()
|
||||
|
||||
## Triggered when the list of favorite materials is changed.
|
||||
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:
|
||||
self._onChanged()
|
||||
|
||||
## This is an abstract method that needs to be implemented by the specific
|
||||
# models themselves.
|
||||
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(";"))
|
||||
|
||||
# Update the available materials (ContainerNode) for the current active machine and extruder setup.
|
||||
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.
|
||||
extruder_stack = global_stack.extruders.get(str(self._extruder_position))
|
||||
if not extruder_stack:
|
||||
extruder_list = global_stack.extruderList
|
||||
if self._extruder_position > len(extruder_list):
|
||||
return
|
||||
extruder_stack = extruder_list[self._extruder_position]
|
||||
nozzle_name = extruder_stack.variant.getName()
|
||||
machine_node = ContainerTree.getInstance().machines[global_stack.definition.getId()]
|
||||
if nozzle_name not in machine_node.variants:
|
||||
|
@ -163,23 +170,23 @@ class BaseMaterialsModel(ListModel):
|
|||
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}
|
||||
|
||||
## 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):
|
||||
"""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
|
||||
if global_stack is None or not self._enabled:
|
||||
return False
|
||||
|
||||
extruder_position = str(self._extruder_position)
|
||||
if extruder_position not in global_stack.extruders:
|
||||
if self._extruder_position >= len(global_stack.extruderList):
|
||||
return False
|
||||
|
||||
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):
|
||||
"""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)
|
||||
if not metadata_list:
|
||||
return None
|
||||
|
|
|
@ -14,9 +14,8 @@ if TYPE_CHECKING:
|
|||
from UM.Settings.Interfaces import ContainerInterface
|
||||
|
||||
|
||||
## This model is used for the custom profile items in the profile drop down
|
||||
# menu.
|
||||
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:
|
||||
super().__init__(parent)
|
||||
|
|
77
cura/Machines/Models/DiscoveredCloudPrintersModel.py
Normal file
77
cura/Machines/Models/DiscoveredCloudPrintersModel.py
Normal 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)
|
|
@ -72,8 +72,6 @@ class DiscoveredPrinter(QObject):
|
|||
# Human readable machine type string
|
||||
@pyqtProperty(str, notify = machineTypeChanged)
|
||||
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
|
||||
# "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.
|
||||
|
@ -117,12 +115,11 @@ class DiscoveredPrinter(QObject):
|
|||
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):
|
||||
"""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:
|
||||
super().__init__(parent)
|
||||
|
@ -131,6 +128,7 @@ class DiscoveredPrintersModel(QObject):
|
|||
self._discovered_printer_by_ip_dict = dict() # type: Dict[str, DiscoveredPrinter]
|
||||
|
||||
self._plugin_for_manual_device = None # type: Optional[OutputDevicePlugin]
|
||||
self._network_plugin_queue = [] # type: List[OutputDevicePlugin]
|
||||
self._manual_device_address = ""
|
||||
|
||||
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()
|
||||
|
||||
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,
|
||||
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)
|
||||
return
|
||||
|
||||
plugin = max(can_add_manual_plugins, key = lambda p: priority_order.index(p.canAddManualDevice(address)))
|
||||
self._plugin_for_manual_device = plugin
|
||||
self._plugin_for_manual_device.addManualDevice(address, callback = self._onManualDeviceRequestFinished)
|
||||
self._manual_device_address = address
|
||||
self._manual_device_request_timer.start()
|
||||
self.hasManualDeviceRequestInProgressChanged.emit()
|
||||
self._attemptToAddManualDevice(address)
|
||||
|
||||
def _attemptToAddManualDevice(self, address: str) -> None:
|
||||
if self._network_plugin_queue:
|
||||
self._plugin_for_manual_device = self._network_plugin_queue.pop()
|
||||
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()
|
||||
def cancelCurrentManualDeviceRequest(self) -> None:
|
||||
|
@ -183,8 +186,11 @@ class DiscoveredPrintersModel(QObject):
|
|||
self.manualDeviceRequestFinished.emit(False)
|
||||
|
||||
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()
|
||||
if self._network_plugin_queue:
|
||||
self._attemptToAddManualDevice(address)
|
||||
|
||||
hasManualDeviceRequestInProgressChanged = pyqtSignal()
|
||||
|
||||
|
@ -200,11 +206,13 @@ class DiscoveredPrintersModel(QObject):
|
|||
self._manual_device_address = ""
|
||||
self.hasManualDeviceRequestInProgressChanged.emit()
|
||||
self.manualDeviceRequestFinished.emit(success)
|
||||
if not success and self._network_plugin_queue:
|
||||
self._attemptToAddManualDevice(address)
|
||||
|
||||
@pyqtProperty("QVariantMap", notify = discoveredPrintersChanged)
|
||||
def discoveredPrintersByAddress(self) -> Dict[str, DiscoveredPrinter]:
|
||||
return self._discovered_printer_by_ip_dict
|
||||
|
||||
|
||||
@pyqtProperty("QVariantList", notify = discoveredPrintersChanged)
|
||||
def discoveredPrinters(self) -> List["DiscoveredPrinter"]:
|
||||
item_list = list(
|
||||
|
@ -256,8 +264,14 @@ class DiscoveredPrintersModel(QObject):
|
|||
del self._discovered_printer_by_ip_dict[ip_address]
|
||||
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")
|
||||
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())
|
||||
|
|
|
@ -15,27 +15,27 @@ if TYPE_CHECKING:
|
|||
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):
|
||||
"""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.
|
||||
IdRole = Qt.UserRole + 1
|
||||
|
||||
## Human-readable name of the extruder.
|
||||
NameRole = Qt.UserRole + 2
|
||||
"""Human-readable name of the extruder."""
|
||||
|
||||
## Colour of the material loaded in the extruder.
|
||||
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
|
||||
"""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.
|
||||
DefinitionRole = Qt.UserRole + 5
|
||||
|
@ -50,18 +50,21 @@ class ExtrudersModel(ListModel):
|
|||
MaterialBrandRole = Qt.UserRole + 9
|
||||
ColorNameRole = Qt.UserRole + 10
|
||||
|
||||
## Is the extruder enabled?
|
||||
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"]
|
||||
"""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):
|
||||
"""Initialises the extruders model, defining the roles and listening for changes in the data.
|
||||
|
||||
:param parent: Parent QtObject of this list.
|
||||
"""
|
||||
|
||||
super().__init__(parent)
|
||||
|
||||
self.addRoleName(self.IdRole, "id")
|
||||
|
@ -75,6 +78,7 @@ class ExtrudersModel(ListModel):
|
|||
self.addRoleName(self.StackRole, "stack")
|
||||
self.addRoleName(self.MaterialBrandRole, "material_brand")
|
||||
self.addRoleName(self.ColorNameRole, "color_name")
|
||||
self.addRoleName(self.MaterialTypeRole, "material_type")
|
||||
self._update_extruder_timer = QTimer()
|
||||
self._update_extruder_timer.setInterval(100)
|
||||
self._update_extruder_timer.setSingleShot(True)
|
||||
|
@ -101,14 +105,15 @@ class ExtrudersModel(ListModel):
|
|||
def addOptionalExtruder(self):
|
||||
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):
|
||||
"""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()
|
||||
if machine_id is not None:
|
||||
if machine_manager.activeMachine is None:
|
||||
|
@ -146,11 +151,13 @@ class ExtrudersModel(ListModel):
|
|||
def _updateExtruders(self):
|
||||
self._update_extruder_timer.start()
|
||||
|
||||
## Update the list of extruders.
|
||||
#
|
||||
# This should be called whenever the list of extruders changes.
|
||||
@UM.FlameProfiler.profile
|
||||
def __updateExtruders(self):
|
||||
"""Update the list of extruders.
|
||||
|
||||
This should be called whenever the list of extruders changes.
|
||||
"""
|
||||
|
||||
extruders_changed = False
|
||||
|
||||
if self.count != 0:
|
||||
|
@ -190,7 +197,8 @@ class ExtrudersModel(ListModel):
|
|||
"variant": extruder.variant.getName() if extruder.variant else "", # e.g. print core
|
||||
"stack": extruder,
|
||||
"material_brand": material_brand,
|
||||
"color_name": color_name
|
||||
"color_name": color_name,
|
||||
"material_type": extruder.material.getMetaDataEntry("material") if extruder.material else "",
|
||||
}
|
||||
|
||||
items.append(item)
|
||||
|
@ -207,7 +215,7 @@ class ExtrudersModel(ListModel):
|
|||
"id": "",
|
||||
"name": catalog.i18nc("@menuitem", "Not overridden"),
|
||||
"enabled": True,
|
||||
"color": "#ffffff",
|
||||
"color": "transparent",
|
||||
"index": -1,
|
||||
"definition": "",
|
||||
"material": "",
|
||||
|
@ -215,6 +223,7 @@ class ExtrudersModel(ListModel):
|
|||
"stack": None,
|
||||
"material_brand": "",
|
||||
"color_name": "",
|
||||
"material_type": "",
|
||||
}
|
||||
items.append(item)
|
||||
if self._items != items:
|
||||
|
|
|
@ -4,16 +4,17 @@
|
|||
from cura.Machines.Models.BaseMaterialsModel import BaseMaterialsModel
|
||||
import cura.CuraApplication # To listen to changes to the preferences.
|
||||
|
||||
## Model that shows the list of favorite materials.
|
||||
class FavoriteMaterialsModel(BaseMaterialsModel):
|
||||
"""Model that shows the list of favorite materials."""
|
||||
|
||||
def __init__(self, parent = None):
|
||||
super().__init__(parent)
|
||||
cura.CuraApplication.CuraApplication.getInstance().getPreferences().preferenceChanged.connect(self._onFavoritesChanged)
|
||||
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:
|
||||
"""Triggered when any preference changes, but only handles it when the list of favourites is changed. """
|
||||
|
||||
if preference_key != "cura/favorite_materials":
|
||||
return
|
||||
self._onChanged()
|
||||
|
|
|
@ -11,13 +11,13 @@ if TYPE_CHECKING:
|
|||
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):
|
||||
"""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
|
||||
ContentRole = Qt.UserRole + 2
|
||||
|
@ -73,9 +73,10 @@ class FirstStartMachineActionsModel(ListModel):
|
|||
self._current_action_index += 1
|
||||
self.currentActionIndexChanged.emit()
|
||||
|
||||
# Resets the current action index to 0 so the wizard panel can show actions from the beginning.
|
||||
@pyqtSlot()
|
||||
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.currentActionIndexChanged.emit()
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ class GlobalStacksModel(ListModel):
|
|||
ConnectionTypeRole = Qt.UserRole + 4
|
||||
MetaDataRole = Qt.UserRole + 5
|
||||
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:
|
||||
super().__init__(parent)
|
||||
|
@ -42,8 +43,9 @@ class GlobalStacksModel(ListModel):
|
|||
CuraContainerRegistry.getInstance().containerRemoved.connect(self._onContainerChanged)
|
||||
self._updateDelayed()
|
||||
|
||||
## Handler for container added/removed events from registry
|
||||
def _onContainerChanged(self, container) -> None:
|
||||
"""Handler for container added/removed events from registry"""
|
||||
|
||||
# We only need to update when the added / removed container GlobalStack
|
||||
if isinstance(container, GlobalStack):
|
||||
self._updateDelayed()
|
||||
|
@ -65,13 +67,21 @@ class GlobalStacksModel(ListModel):
|
|||
if parseBool(container_stack.getMetaDataEntry("hidden", False)):
|
||||
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)
|
||||
|
||||
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(),
|
||||
"hasRemoteConnection": has_remote_connection,
|
||||
"metadata": container_stack.getMetaData().copy(),
|
||||
"discoverySource": section_name})
|
||||
items.sort(key = lambda i: (not i["hasRemoteConnection"], i["name"]))
|
||||
"discoverySource": section_name,
|
||||
"removalWarning": removal_warning})
|
||||
items.sort(key=lambda i: (not i["hasRemoteConnection"], i["name"]))
|
||||
self.setItems(items)
|
||||
|
|
|
@ -4,13 +4,12 @@
|
|||
import collections
|
||||
from PyQt5.QtCore import Qt, QTimer
|
||||
from typing import TYPE_CHECKING, Optional, Dict
|
||||
from cura.Machines.Models.IntentTranslations import intent_translations
|
||||
|
||||
from cura.Machines.Models.IntentModel import IntentModel
|
||||
from cura.Settings.IntentManager import IntentManager
|
||||
from UM.Qt.ListModel import ListModel
|
||||
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
|
||||
if TYPE_CHECKING:
|
||||
from UM.Settings.ContainerRegistry import ContainerInterface
|
||||
|
@ -19,9 +18,9 @@ from UM.i18n import i18nCatalog
|
|||
catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
## Lists the intent categories that are available for the current printer
|
||||
# configuration.
|
||||
class IntentCategoryModel(ListModel):
|
||||
"""Lists the intent categories that are available for the current printer configuration. """
|
||||
|
||||
NameRole = Qt.UserRole + 1
|
||||
IntentCategoryRole = Qt.UserRole + 2
|
||||
WeightRole = Qt.UserRole + 3
|
||||
|
@ -32,10 +31,12 @@ class IntentCategoryModel(ListModel):
|
|||
|
||||
_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
|
||||
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:
|
||||
cls._translations["default"] = {
|
||||
"name": catalog.i18nc("@label", "Default")
|
||||
|
@ -54,9 +55,12 @@ class IntentCategoryModel(ListModel):
|
|||
}
|
||||
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:
|
||||
"""Creates a new model for a certain intent category.
|
||||
|
||||
:param intent_category: category to list the intent profiles for.
|
||||
"""
|
||||
|
||||
super().__init__()
|
||||
self._intent_category = intent_category
|
||||
|
||||
|
@ -85,16 +89,18 @@ class IntentCategoryModel(ListModel):
|
|||
|
||||
self.update()
|
||||
|
||||
## Updates the list of intents if an intent profile was added or removed.
|
||||
def _onContainerChange(self, container: "ContainerInterface") -> None:
|
||||
"""Updates the list of intents if an intent profile was added or removed."""
|
||||
|
||||
if container.getMetaDataEntry("type") == "intent":
|
||||
self.update()
|
||||
|
||||
def update(self):
|
||||
self._update_timer.start()
|
||||
|
||||
## Updates the list of intents.
|
||||
def _update(self) -> None:
|
||||
"""Updates the list of intents."""
|
||||
|
||||
available_categories = IntentManager.getInstance().currentAvailableIntentCategories()
|
||||
result = []
|
||||
for category in available_categories:
|
||||
|
@ -110,9 +116,9 @@ class IntentCategoryModel(ListModel):
|
|||
result.sort(key = lambda k: k["weight"])
|
||||
self.setItems(result)
|
||||
|
||||
## Get a display value for a category.
|
||||
## for categories and keys
|
||||
@staticmethod
|
||||
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, {})
|
||||
return display_strings.get(key, default)
|
||||
|
|
|
@ -98,8 +98,9 @@ class IntentModel(ListModel):
|
|||
new_items = sorted(new_items, key = lambda x: x["layer_height"])
|
||||
self.setItems(new_items)
|
||||
|
||||
## Get the active materials for all extruders. No duplicates will be returned
|
||||
def _getActiveMaterials(self) -> Set["MaterialNode"]:
|
||||
"""Get the active materials for all extruders. No duplicates will be returned"""
|
||||
|
||||
global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
|
||||
if global_stack is None:
|
||||
return set()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
@ -34,4 +34,4 @@ def fetchLayerHeight(quality_group: "QualityGroup") -> float:
|
|||
if isinstance(layer_height, SettingFunction):
|
||||
layer_height = layer_height(global_stack)
|
||||
|
||||
return float(layer_height)
|
||||
return round(float(layer_height), 3)
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import copy # To duplicate materials.
|
||||
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot # To allow the preference page proxy to be used from the actual preferences page.
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl
|
||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||
import uuid # To generate new GUIDs for new materials.
|
||||
import zipfile # To export all materials in a .zip archive.
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Logger import Logger
|
||||
|
@ -19,28 +20,26 @@ if TYPE_CHECKING:
|
|||
|
||||
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):
|
||||
## 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)
|
||||
"""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)
|
||||
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()
|
||||
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"):
|
||||
|
@ -48,11 +47,14 @@ class MaterialManagementModel(QObject):
|
|||
return False
|
||||
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)
|
||||
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()
|
||||
root_material_id = material_node.base_file
|
||||
if container_registry.isReadOnly(root_material_id):
|
||||
|
@ -60,18 +62,21 @@ class MaterialManagementModel(QObject):
|
|||
return
|
||||
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")
|
||||
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()
|
||||
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:
|
||||
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,
|
||||
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()
|
||||
|
||||
root_materials = container_registry.findContainers(id = base_file)
|
||||
|
@ -171,29 +178,33 @@ class MaterialManagementModel(QObject):
|
|||
|
||||
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)
|
||||
def duplicateMaterial(self, material_node: "MaterialNode", new_base_id: Optional[str] = None,
|
||||
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)
|
||||
|
||||
## 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)
|
||||
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.
|
||||
application = cura.CuraApplication.CuraApplication.getInstance()
|
||||
application.saveSettings()
|
||||
|
@ -218,10 +229,13 @@ class MaterialManagementModel(QObject):
|
|||
self.duplicateMaterial(preferred_material_node, new_base_id = new_id, new_metadata = new_metadata)
|
||||
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)
|
||||
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()
|
||||
favorites = application.getPreferences().getValue("cura/favorite_materials").split(";")
|
||||
if material_base_file not in favorites:
|
||||
|
@ -230,11 +244,13 @@ class MaterialManagementModel(QObject):
|
|||
application.saveSettings()
|
||||
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)
|
||||
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()
|
||||
favorites = application.getPreferences().getValue("cura/favorite_materials").split(";")
|
||||
try:
|
||||
|
@ -244,3 +260,40 @@ class MaterialManagementModel(QObject):
|
|||
self.favoritesChanged.emit(material_base_file)
|
||||
except ValueError: # Material was not in the favorites list.
|
||||
Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file))
|
||||
|
||||
@pyqtSlot(result = QUrl)
|
||||
def getPreferredExportAllPath(self) -> QUrl:
|
||||
"""
|
||||
Get the preferred path to export materials to.
|
||||
|
||||
If there is a removable drive, that should be the preferred path. Otherwise it should be the most recent local
|
||||
file path.
|
||||
:return: The preferred path to export all materials to.
|
||||
"""
|
||||
cura_application = cura.CuraApplication.CuraApplication.getInstance()
|
||||
device_manager = cura_application.getOutputDeviceManager()
|
||||
devices = device_manager.getOutputDevices()
|
||||
for device in devices:
|
||||
if device.__class__.__name__ == "RemovableDriveOutputDevice":
|
||||
return QUrl.fromLocalFile(device.getId())
|
||||
else: # No removable drives? Use local path.
|
||||
return cura_application.getDefaultPath("dialog_material_path")
|
||||
|
||||
@pyqtSlot(QUrl)
|
||||
def exportAll(self, file_path: QUrl) -> None:
|
||||
"""
|
||||
Export all materials to a certain file path.
|
||||
:param file_path: The path to export the materials to.
|
||||
"""
|
||||
registry = CuraContainerRegistry.getInstance()
|
||||
|
||||
archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED)
|
||||
for metadata in registry.findInstanceContainersMetadata(type = "material"):
|
||||
if metadata["base_file"] != metadata["id"]: # Only process base files.
|
||||
continue
|
||||
if metadata["id"] == "empty_material": # Don't export the empty material.
|
||||
continue
|
||||
material = registry.findContainers(id = metadata["id"])[0]
|
||||
suffix = registry.getMimeTypeForContainer(type(material)).preferredSuffix
|
||||
filename = metadata["id"] + "." + suffix
|
||||
archive.writestr(filename, material.serialize())
|
||||
|
|
|
@ -9,11 +9,11 @@ from UM.Scene.Selection import Selection
|
|||
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):
|
||||
"""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()
|
||||
activeBuildPlateChanged = pyqtSignal()
|
||||
|
@ -39,9 +39,10 @@ class MultiBuildPlateModel(ListModel):
|
|||
self._max_build_plate = max_build_plate
|
||||
self.maxBuildPlateChanged.emit()
|
||||
|
||||
## Return the highest build plate number
|
||||
@pyqtProperty(int, notify = maxBuildPlateChanged)
|
||||
def maxBuildPlate(self):
|
||||
"""Return the highest build plate number"""
|
||||
|
||||
return self._max_build_plate
|
||||
|
||||
def setActiveBuildPlate(self, nr):
|
||||
|
|
|
@ -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.
|
||||
|
||||
from typing import Any, cast, Dict, Optional, TYPE_CHECKING
|
||||
|
@ -26,10 +26,9 @@ if TYPE_CHECKING:
|
|||
from cura.Settings.GlobalStack import GlobalStack
|
||||
|
||||
|
||||
#
|
||||
# This the QML model for the quality management page.
|
||||
#
|
||||
class QualityManagementModel(ListModel):
|
||||
"""This the QML model for the quality management page."""
|
||||
|
||||
NameRole = Qt.UserRole + 1
|
||||
IsReadOnlyRole = Qt.UserRole + 2
|
||||
QualityGroupRole = Qt.UserRole + 3
|
||||
|
@ -74,11 +73,13 @@ class QualityManagementModel(ListModel):
|
|||
def _onChange(self) -> None:
|
||||
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)
|
||||
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))
|
||||
removed_quality_changes_ids = set()
|
||||
container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
|
||||
|
@ -95,16 +96,19 @@ class QualityManagementModel(ListModel):
|
|||
if extruder_stack.qualityChanges.getId() in removed_quality_changes_ids:
|
||||
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)
|
||||
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))
|
||||
if new_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():
|
||||
extruder_container = cast(InstanceContainer, container_registry.findContainers(id = metadata["id"])[0])
|
||||
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)
|
||||
|
||||
quality_changes_group.name = new_name
|
||||
|
@ -138,13 +142,16 @@ class QualityManagementModel(ListModel):
|
|||
|
||||
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")
|
||||
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()
|
||||
if not global_stack:
|
||||
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_changes_group = quality_model_item["quality_changes_group"]
|
||||
if quality_changes_group is None:
|
||||
# Create global quality changes only.
|
||||
new_quality_changes = self._createQualityChanges(quality_group.quality_type, intent_category, new_name,
|
||||
global_stack, extruder_stack = None)
|
||||
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:
|
||||
for metadata in [quality_changes_group.metadata_for_global] + list(quality_changes_group.metadata_per_extruder.values()):
|
||||
containers = container_registry.findContainers(id = metadata["id"])
|
||||
|
@ -170,18 +183,18 @@ class QualityManagementModel(ListModel):
|
|||
new_id = container_registry.uniqueName(container.getId())
|
||||
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)
|
||||
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()
|
||||
|
||||
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.
|
||||
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:
|
||||
quality_container = stack.quality
|
||||
quality_changes_container = stack.qualityChanges
|
||||
|
@ -220,14 +233,16 @@ class QualityManagementModel(ListModel):
|
|||
|
||||
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":
|
||||
"""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()
|
||||
base_id = machine.definition.getId() if extruder_stack is None else extruder_stack.getId()
|
||||
new_id = base_id + "_" + new_name
|
||||
|
@ -253,11 +268,13 @@ class QualityManagementModel(ListModel):
|
|||
quality_changes.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.getInstance().SettingVersion)
|
||||
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:
|
||||
"""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":
|
||||
self._update()
|
||||
|
||||
|
@ -322,6 +339,7 @@ class QualityManagementModel(ListModel):
|
|||
"layer_height": layer_height, # layer_height is only used for sorting
|
||||
}
|
||||
item_list.append(item)
|
||||
|
||||
# Sort by layer_height for built-in qualities
|
||||
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"]
|
||||
result = []
|
||||
for intent_category, quality_type in available_intent_list:
|
||||
if not quality_group_dict[quality_type].is_available:
|
||||
continue
|
||||
|
||||
result.append({
|
||||
"name": quality_group_dict[quality_type].name, # Use the quality name as the display name
|
||||
"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.
|
||||
quality_group = quality_group_dict.get(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,
|
||||
"is_read_only": False,
|
||||
"quality_group": quality_group,
|
||||
|
@ -366,18 +390,19 @@ class QualityManagementModel(ListModel):
|
|||
|
||||
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")
|
||||
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
|
||||
catalog = i18nCatalog("uranium")
|
||||
#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.
|
||||
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):
|
||||
"""Gets a list of profile reader or writer plugins
|
||||
|
||||
:return: List of tuples of (plugin_id, meta_data).
|
||||
"""
|
||||
from UM.PluginRegistry import PluginRegistry
|
||||
pr = PluginRegistry.getInstance()
|
||||
active_plugin_ids = pr.getActivePlugins()
|
||||
|
|
|
@ -10,10 +10,9 @@ from cura.Machines.ContainerTree import ContainerTree
|
|||
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):
|
||||
"""QML Model for all built-in quality profiles. This model is used for the drop-down quality menu."""
|
||||
|
||||
NameRole = Qt.UserRole + 1
|
||||
QualityTypeRole = Qt.UserRole + 2
|
||||
LayerHeightRole = Qt.UserRole + 3
|
||||
|
|
|
@ -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.
|
||||
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSignal, Qt
|
||||
from typing import Set
|
||||
|
||||
import cura.CuraApplication
|
||||
from UM import i18nCatalog
|
||||
from UM.Logger import Logger
|
||||
from UM.Qt.ListModel import ListModel
|
||||
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):
|
||||
"""This model is used to show details settings of the selected quality in the quality management page."""
|
||||
|
||||
KeyRole = Qt.UserRole + 1
|
||||
LabelRole = Qt.UserRole + 2
|
||||
UnitRole = Qt.UserRole + 3
|
||||
|
@ -82,6 +84,12 @@ class QualitySettingsModel(ListModel):
|
|||
global_container_stack = self._application.getGlobalContainerStack()
|
||||
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_changes_group = self._selected_quality_item["quality_changes_group"]
|
||||
|
||||
|
@ -91,7 +99,7 @@ class QualitySettingsModel(ListModel):
|
|||
if self._selected_position == self.GLOBAL_STACK_POSITION:
|
||||
quality_node = quality_group.node_for_global
|
||||
else:
|
||||
quality_node = quality_group.nodes_for_extruders.get(str(self._selected_position))
|
||||
quality_node = quality_group.nodes_for_extruders.get(self._selected_position)
|
||||
settings_keys = quality_group.getAllKeys()
|
||||
quality_containers = []
|
||||
if quality_node is not None and quality_node.container is not None:
|
||||
|
@ -101,14 +109,18 @@ class QualitySettingsModel(ListModel):
|
|||
# the settings in that quality_changes_group.
|
||||
if quality_changes_group is not None:
|
||||
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]
|
||||
extruders_containers = {pos: container_registry.findContainers(id = quality_changes_group.metadata_per_extruder[pos]["id"]) for pos in quality_changes_group.metadata_per_extruder}
|
||||
extruders_container = {pos: None if not containers else containers[0] for pos, containers in extruders_containers.items()}
|
||||
quality_changes_metadata = None
|
||||
if self._selected_position == self.GLOBAL_STACK_POSITION and global_container:
|
||||
quality_changes_metadata = global_container.getMetaData()
|
||||
else:
|
||||
quality_changes_metadata = extruders_container.get(str(self._selected_position))
|
||||
extruder = extruders_container.get(self._selected_position)
|
||||
if extruder:
|
||||
quality_changes_metadata = extruder.getMetaData()
|
||||
if quality_changes_metadata is not None: # It can be None if number of extruders are changed during runtime.
|
||||
container = container_registry.findContainers(id = quality_changes_metadata["id"])
|
||||
if container:
|
||||
|
@ -152,7 +164,7 @@ class QualitySettingsModel(ListModel):
|
|||
if self._selected_position == self.GLOBAL_STACK_POSITION:
|
||||
user_value = global_container_stack.userChanges.getProperty(definition.key, "value")
|
||||
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")
|
||||
|
||||
if profile_value is None and user_value is None:
|
||||
|
|
|
@ -19,6 +19,8 @@ class SettingVisibilityPresetsModel(QObject):
|
|||
onItemsChanged = pyqtSignal()
|
||||
activePresetChanged = pyqtSignal()
|
||||
|
||||
Version = 2
|
||||
|
||||
def __init__(self, preferences: Preferences, parent = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
import os
|
||||
|
@ -7,6 +7,7 @@ from collections import OrderedDict
|
|||
from PyQt5.QtCore import pyqtSlot, Qt
|
||||
|
||||
from UM.Application import Application
|
||||
from UM.Logger import Logger
|
||||
from UM.Settings.ContainerRegistry import ContainerRegistry
|
||||
from UM.i18n import i18nCatalog
|
||||
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.
|
||||
category = user_changes.getInstance(setting_key).definition
|
||||
while category.type != "category":
|
||||
while category is not None and category.type != "category":
|
||||
category = category.parent
|
||||
|
||||
# Handle translation (and fallback if we weren't able to find any translation files.
|
||||
if self._i18n_catalog:
|
||||
category_label = self._i18n_catalog.i18nc(category.key + " label", category.label)
|
||||
else:
|
||||
category_label = category.label
|
||||
if category is not None:
|
||||
if self._i18n_catalog:
|
||||
category_label = self._i18n_catalog.i18nc(category.key + " 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:
|
||||
label = self._i18n_catalog.i18nc(setting_key + " label", stack.getProperty(setting_key, "label"))
|
||||
|
|
|
@ -6,12 +6,12 @@ from typing import Any, Dict, Optional
|
|||
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):
|
||||
"""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:
|
||||
super().__init__(parent)
|
||||
|
|
|
@ -3,36 +3,40 @@
|
|||
|
||||
from typing import Dict, Optional, List, Set
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSlot
|
||||
|
||||
from UM.Logger import Logger
|
||||
from UM.Util import parseBool
|
||||
|
||||
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:
|
||||
## 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.
|
||||
"""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.
|
||||
"""
|
||||
|
||||
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.node_for_global = None # type: Optional[ContainerNode]
|
||||
self.nodes_for_extruders = {} # type: Dict[int, ContainerNode]
|
||||
|
|
|
@ -13,12 +13,14 @@ if TYPE_CHECKING:
|
|||
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):
|
||||
"""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:
|
||||
super().__init__(container_id)
|
||||
self.parent = parent
|
||||
|
|
|
@ -17,16 +17,16 @@ if TYPE_CHECKING:
|
|||
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):
|
||||
"""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:
|
||||
super().__init__(container_id)
|
||||
self.machine = machine
|
||||
|
@ -39,9 +39,10 @@ class VariantNode(ContainerNode):
|
|||
container_registry.containerRemoved.connect(self._materialRemoved)
|
||||
self._loadAll()
|
||||
|
||||
## (Re)loads all materials under this variant.
|
||||
@UM.FlameProfiler.profile
|
||||
def _loadAll(self) -> None:
|
||||
"""(Re)loads all materials under this variant."""
|
||||
|
||||
container_registry = ContainerRegistry.getInstance()
|
||||
|
||||
if not self.machine.has_materials:
|
||||
|
@ -69,29 +70,29 @@ class VariantNode(ContainerNode):
|
|||
if not self.materials:
|
||||
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:
|
||||
"""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():
|
||||
if self.machine.preferred_material == base_material and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")):
|
||||
return material_node
|
||||
|
||||
|
||||
# First fallback: Check if we should be checking for the 175 variant.
|
||||
if approximate_diameter == 2:
|
||||
preferred_material = self.machine.preferred_material + "_175"
|
||||
for base_material, material_node in self.materials.items():
|
||||
if preferred_material == base_material and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")):
|
||||
return material_node
|
||||
|
||||
|
||||
# Second fallback: Choose any material with matching diameter.
|
||||
for material_node in self.materials.values():
|
||||
if material_node.getMetaDataEntry("approximate_diameter") and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")):
|
||||
|
@ -107,10 +108,10 @@ class VariantNode(ContainerNode):
|
|||
))
|
||||
return fallback
|
||||
|
||||
## When a material gets added to the set of profiles, we need to update our
|
||||
# tree here.
|
||||
@UM.FlameProfiler.profile
|
||||
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":
|
||||
return # Not interested.
|
||||
if not ContainerRegistry.getInstance().findContainersMetadata(id = container.getId()):
|
||||
|
|
|
@ -4,19 +4,16 @@
|
|||
import copy
|
||||
from typing import List
|
||||
|
||||
from UM.Application import Application
|
||||
from UM.Job import Job
|
||||
from UM.Operations.GroupedOperation import GroupedOperation
|
||||
from UM.Message import Message
|
||||
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from UM.i18n import i18nCatalog
|
||||
from cura.Arranging.Nest2DArrange import arrange
|
||||
|
||||
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):
|
||||
def __init__(self, objects, count, min_offset = 8):
|
||||
|
@ -26,28 +23,27 @@ class MultiplyObjectsJob(Job):
|
|||
self._min_offset = min_offset
|
||||
|
||||
def run(self) -> None:
|
||||
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"))
|
||||
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"))
|
||||
status_message.show()
|
||||
scene = Application.getInstance().getController().getScene()
|
||||
|
||||
total_progress = len(self._objects) * self._count
|
||||
current_progress = 0
|
||||
|
||||
global_container_stack = Application.getInstance().getGlobalContainerStack()
|
||||
if global_container_stack is None:
|
||||
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()
|
||||
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]
|
||||
nodes = []
|
||||
|
||||
not_fit_count = 0
|
||||
found_solution_for_all = False
|
||||
fixed_nodes = []
|
||||
for node_ in DepthFirstIterator(root):
|
||||
# Only count sliceable objects
|
||||
if node_.callDecoration("isSliceable"):
|
||||
fixed_nodes.append(node_)
|
||||
|
||||
for node in self._objects:
|
||||
# If object is part of a group, multiply group
|
||||
current_node = node
|
||||
|
@ -58,31 +54,8 @@ class MultiplyObjectsJob(Job):
|
|||
continue
|
||||
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):
|
||||
# We do place the nodes one by one, as we want to yield in between.
|
||||
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
|
||||
build_plate_number = current_node.callDecoration("getBuildPlateNumber")
|
||||
|
@ -91,19 +64,16 @@ class MultiplyObjectsJob(Job):
|
|||
child.callDecoration("setBuildPlateNumber", build_plate_number)
|
||||
|
||||
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:
|
||||
operation = GroupedOperation()
|
||||
for new_node in nodes:
|
||||
operation.addOperation(AddSceneNodeOperation(new_node, current_node.getParent()))
|
||||
operation.push()
|
||||
found_solution_for_all = arrange(nodes, Application.getInstance().getBuildVolume(), fixed_nodes,
|
||||
factor = 10000, add_new_nodes_in_scene = True)
|
||||
status_message.hide()
|
||||
|
||||
if not found_solution_for_all:
|
||||
no_full_solution_message = Message(i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"), title = i18n_catalog.i18nc("@info:title", "Placing Object"))
|
||||
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()
|
||||
|
|
|
@ -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.
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import random
|
||||
from hashlib import sha512
|
||||
from base64 import b64encode
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
|
@ -16,23 +16,28 @@ from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settin
|
|||
catalog = i18nCatalog("cura")
|
||||
TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
## Class containing several helpers to deal with the authorization flow.
|
||||
|
||||
class AuthorizationHelpers:
|
||||
"""Class containing several helpers to deal with the authorization flow."""
|
||||
|
||||
def __init__(self, settings: "OAuth2Settings") -> None:
|
||||
self._settings = settings
|
||||
self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL)
|
||||
|
||||
@property
|
||||
## The OAuth2 settings object.
|
||||
def settings(self) -> "OAuth2Settings":
|
||||
"""The OAuth2 settings object."""
|
||||
|
||||
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":
|
||||
"""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 = {
|
||||
"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 "",
|
||||
|
@ -46,11 +51,14 @@ class AuthorizationHelpers:
|
|||
except requests.exceptions.ConnectionError:
|
||||
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":
|
||||
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 = {
|
||||
"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 "",
|
||||
|
@ -61,13 +69,18 @@ class AuthorizationHelpers:
|
|||
try:
|
||||
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore
|
||||
except requests.exceptions.ConnectionError:
|
||||
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server")
|
||||
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
|
||||
## 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":
|
||||
"""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
|
||||
|
||||
try:
|
||||
|
@ -89,15 +102,20 @@ class AuthorizationHelpers:
|
|||
scope=token_data["scope"],
|
||||
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"]:
|
||||
"""Calls the authentication API endpoint to get the token data.
|
||||
|
||||
:param access_token: The encoded JWT token.
|
||||
:return: Dict containing some profile data.
|
||||
"""
|
||||
|
||||
try:
|
||||
token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = {
|
||||
check_token_url = "{}/check-token".format(self._settings.OAUTH_SERVER_URL)
|
||||
Logger.log("d", "Checking the access token for [%s]", check_token_url)
|
||||
token_request = requests.get(check_token_url, headers = {
|
||||
"Authorization": "Bearer {}".format(access_token)
|
||||
})
|
||||
except requests.exceptions.ConnectionError:
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||
# Connection was suddenly dropped. Nothing we can do about that.
|
||||
Logger.logException("w", "Something failed while attempting to parse the JWT token")
|
||||
return None
|
||||
|
@ -108,23 +126,32 @@ class AuthorizationHelpers:
|
|||
if not user_data or not isinstance(user_data, dict):
|
||||
Logger.log("w", "Could not parse user data from token: %s", user_data)
|
||||
return None
|
||||
|
||||
return UserProfile(
|
||||
user_id = user_data["user_id"],
|
||||
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
|
||||
## 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:
|
||||
"""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))
|
||||
|
||||
@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:
|
||||
"""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()
|
||||
return b64encode(encoded, altchars = b"_-").decode()
|
||||
|
|
|
@ -14,9 +14,12 @@ if TYPE_CHECKING:
|
|||
|
||||
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):
|
||||
"""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:
|
||||
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.
|
||||
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]]:
|
||||
"""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")
|
||||
state = self._queryGet(query, "state")
|
||||
if state != self.state:
|
||||
|
@ -95,9 +101,10 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
|
|||
self.authorization_helpers.settings.AUTH_FAILED_REDIRECT
|
||||
), token_response
|
||||
|
||||
## Handle all other non-existing server calls.
|
||||
@staticmethod
|
||||
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.")
|
||||
|
||||
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:
|
||||
self.wfile.write(data)
|
||||
|
||||
## Convenience helper for getting values from a pre-parsed query string
|
||||
@staticmethod
|
||||
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]
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from http.server import HTTPServer
|
||||
from socketserver import ThreadingMixIn
|
||||
from typing import Callable, Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
@ -9,21 +10,26 @@ if TYPE_CHECKING:
|
|||
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers
|
||||
|
||||
|
||||
## The authorization request callback handler server.
|
||||
# This subclass is needed to be able to pass some data to the request handler.
|
||||
# This cannot be done on the request handler directly as the HTTPServer
|
||||
# creates an instance of the handler after init.
|
||||
class AuthorizationRequestServer(HTTPServer):
|
||||
## Set the authorization helpers instance on the request handler.
|
||||
class AuthorizationRequestServer(ThreadingMixIn, HTTPServer):
|
||||
"""The authorization request callback handler server.
|
||||
|
||||
This subclass is needed to be able to pass some data to the request handler. This cannot be done on the request
|
||||
handler directly as the HTTPServer creates an instance of the handler after init.
|
||||
"""
|
||||
|
||||
def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None:
|
||||
"""Set the authorization helpers instance on the request handler."""
|
||||
|
||||
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:
|
||||
"""Set the authorization callback on the request handler."""
|
||||
|
||||
self.RequestHandlerClass.authorization_callback = authorization_callback # type: ignore
|
||||
|
||||
## Set the verification code on the request handler.
|
||||
def setVerificationCode(self, verification_code: str) -> None:
|
||||
"""Set the verification code on the request handler."""
|
||||
|
||||
self.RequestHandlerClass.verification_code = verification_code # type: ignore
|
||||
|
||||
def setState(self, state: str) -> None:
|
||||
|
|
|
@ -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.
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from urllib.parse import urlencode
|
||||
from typing import Optional, TYPE_CHECKING, Dict
|
||||
from urllib.parse import urlencode, quote_plus
|
||||
|
||||
import requests.exceptions
|
||||
|
||||
from PyQt5.QtCore import QUrl
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.Signal import Signal
|
||||
|
||||
from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
|
||||
from UM.i18n import i18nCatalog
|
||||
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT
|
||||
from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
|
||||
from cura.OAuth2.Models import AuthenticationResponse
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
i18n_catalog = i18nCatalog("cura")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.OAuth2.Models import UserProfile, OAuth2Settings
|
||||
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:
|
||||
"""The authorization service is responsible for handling the login flow, storing user credentials and providing
|
||||
account information.
|
||||
"""
|
||||
|
||||
# Emit signal when authentication is completed.
|
||||
onAuthStateChanged = Signal()
|
||||
|
||||
|
@ -61,11 +62,16 @@ class AuthorizationService:
|
|||
if self._preferences:
|
||||
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"]:
|
||||
"""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 no user profile was stored locally, we try to get it from JWT.
|
||||
try:
|
||||
|
@ -83,9 +89,12 @@ class AuthorizationService:
|
|||
|
||||
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"]:
|
||||
"""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 no auth data exists, we should always log in again.
|
||||
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.
|
||||
return None
|
||||
# Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been deleted
|
||||
# from the server already.
|
||||
self._storeAuthData(self._auth_data)
|
||||
# from the server already. Do not store the auth_data if we could not get new auth_data (eg due to a
|
||||
# network error), since this would cause an infinite loop trying to get new auth-data
|
||||
if self._auth_data.success:
|
||||
self._storeAuthData(self._auth_data)
|
||||
return self._auth_helpers.parseJWT(self._auth_data.access_token)
|
||||
|
||||
## Get the access token as provided by the repsonse data.
|
||||
def getAccessToken(self) -> Optional[str]:
|
||||
"""Get the access token as provided by the repsonse data."""
|
||||
|
||||
if self._auth_data is None:
|
||||
Logger.log("d", "No auth data to retrieve the access_token from")
|
||||
return None
|
||||
|
@ -124,8 +136,9 @@ class AuthorizationService:
|
|||
|
||||
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:
|
||||
"""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:
|
||||
Logger.log("w", "Unable to refresh access token, since there is no refresh token.")
|
||||
return
|
||||
|
@ -137,14 +150,16 @@ class AuthorizationService:
|
|||
Logger.log("w", "Failed to get a new access token from the server.")
|
||||
self.onAuthStateChanged.emit(logged_in = False)
|
||||
|
||||
## Delete the authentication data that we have stored locally (eg; logout)
|
||||
def deleteAuthData(self) -> None:
|
||||
"""Delete the authentication data that we have stored locally (eg; logout)"""
|
||||
|
||||
if self._auth_data is not None:
|
||||
self._storeAuthData()
|
||||
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) -> None:
|
||||
def startAuthorizationFlow(self, force_browser_logout: bool = False) -> 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...")
|
||||
|
||||
# Create the tokens needed for the code challenge (PKCE) extension for OAuth2.
|
||||
|
@ -155,8 +170,8 @@ class AuthorizationService:
|
|||
|
||||
state = AuthorizationHelpers.generateVerificationCode()
|
||||
|
||||
# Create the query string needed for the OAuth2 flow.
|
||||
query_string = urlencode({
|
||||
# Create the query dict needed for the OAuth2 flow.
|
||||
query_parameters_dict = {
|
||||
"client_id": self._settings.CLIENT_ID,
|
||||
"redirect_uri": self._settings.CALLBACK_URL,
|
||||
"scope": self._settings.CLIENT_SCOPES,
|
||||
|
@ -164,16 +179,45 @@ class AuthorizationService:
|
|||
"state": state, # Forever in our Hearts, RIP "(.Y.)" (2018-2020)
|
||||
"code_challenge": challenge_code,
|
||||
"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.
|
||||
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:
|
||||
"""Callback method for the authentication flow."""
|
||||
|
||||
if auth_response.success:
|
||||
self._storeAuthData(auth_response)
|
||||
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._server.stop() # Stop the web server at all times.
|
||||
|
||||
## Load authentication data from preferences.
|
||||
def loadAuthDataFromPreferences(self) -> None:
|
||||
"""Load authentication data from preferences."""
|
||||
|
||||
if self._preferences is None:
|
||||
Logger.log("e", "Unable to load authentication data, since no preference has been set!")
|
||||
return
|
||||
|
@ -198,25 +243,28 @@ class AuthorizationService:
|
|||
if self._unable_to_get_data_message is not None:
|
||||
self._unable_to_get_data_message.hide()
|
||||
|
||||
self._unable_to_get_data_message = Message(i18n_catalog.i18nc("@info", "Unable to reach the Ultimaker account server."), title = i18n_catalog.i18nc("@info:title", "Warning"))
|
||||
self._unable_to_get_data_message = Message(i18n_catalog.i18nc("@info",
|
||||
"Unable to reach the Ultimaker account server."),
|
||||
title = i18n_catalog.i18nc("@info:title", "Warning"),
|
||||
message_type = Message.MessageType.ERROR)
|
||||
self._unable_to_get_data_message.show()
|
||||
except ValueError:
|
||||
except (ValueError, TypeError):
|
||||
Logger.logException("w", "Could not load auth data from preferences")
|
||||
|
||||
## Store authentication data in preferences.
|
||||
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:
|
||||
Logger.log("e", "Unable to save authentication data, since no preference has been set!")
|
||||
return
|
||||
|
||||
|
||||
self._auth_data = auth_data
|
||||
if auth_data:
|
||||
self._user_profile = self.getUserProfile()
|
||||
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data)))
|
||||
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(auth_data.dump()))
|
||||
else:
|
||||
self._user_profile = None
|
||||
self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)
|
||||
|
||||
self.accessTokenChanged.emit()
|
||||
|
||||
|
|
83
cura/OAuth2/KeyringAttribute.py
Normal file
83
cura/OAuth2/KeyringAttribute.py
Normal file
|
@ -0,0 +1,83 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Type, TYPE_CHECKING, Optional, List
|
||||
|
||||
import keyring
|
||||
from keyring.backend import KeyringBackend
|
||||
from keyring.errors import NoKeyringError, PasswordSetError, KeyringLocked
|
||||
|
||||
from UM.Logger import Logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.OAuth2.Models import BaseModel
|
||||
|
||||
# Need to do some extra workarounds on windows:
|
||||
import sys
|
||||
from UM.Platform import Platform
|
||||
if Platform.isWindows() and hasattr(sys, "frozen"):
|
||||
import win32timezone
|
||||
from keyring.backends.Windows import WinVaultKeyring
|
||||
keyring.set_keyring(WinVaultKeyring())
|
||||
if Platform.isOSX() and hasattr(sys, "frozen"):
|
||||
from keyring.backends.macOS import Keyring
|
||||
keyring.set_keyring(Keyring())
|
||||
|
||||
# Even if errors happen, we don't want this stored locally:
|
||||
DONT_EVER_STORE_LOCALLY: List[str] = ["refresh_token"]
|
||||
|
||||
|
||||
class KeyringAttribute:
|
||||
"""
|
||||
Descriptor for attributes that need to be stored in the keyring. With Fallback behaviour to the preference cfg file
|
||||
"""
|
||||
def __get__(self, instance: "BaseModel", owner: type) -> Optional[str]:
|
||||
if self._store_secure: # type: ignore
|
||||
try:
|
||||
value = keyring.get_password("cura", self._keyring_name)
|
||||
return value if value != "" else None
|
||||
except NoKeyringError:
|
||||
self._store_secure = False
|
||||
Logger.logException("w", "No keyring backend present")
|
||||
return getattr(instance, self._name)
|
||||
except KeyringLocked:
|
||||
self._store_secure = False
|
||||
Logger.log("i", "Access to the keyring was denied.")
|
||||
return getattr(instance, self._name)
|
||||
else:
|
||||
return getattr(instance, self._name)
|
||||
|
||||
def __set__(self, instance: "BaseModel", value: Optional[str]):
|
||||
if self._store_secure:
|
||||
setattr(instance, self._name, None)
|
||||
if value is not None:
|
||||
try:
|
||||
keyring.set_password("cura", self._keyring_name, value)
|
||||
except (PasswordSetError, KeyringLocked):
|
||||
self._store_secure = False
|
||||
if self._name not in DONT_EVER_STORE_LOCALLY:
|
||||
setattr(instance, self._name, value)
|
||||
Logger.logException("w", "Keyring access denied")
|
||||
except NoKeyringError:
|
||||
self._store_secure = False
|
||||
if self._name not in DONT_EVER_STORE_LOCALLY:
|
||||
setattr(instance, self._name, value)
|
||||
Logger.logException("w", "No keyring backend present")
|
||||
except BaseException as e:
|
||||
# A BaseException can occur in Windows when the keyring attempts to write a token longer than 1024
|
||||
# characters in the Windows Credentials Manager.
|
||||
self._store_secure = False
|
||||
if self._name not in DONT_EVER_STORE_LOCALLY:
|
||||
setattr(instance, self._name, value)
|
||||
Logger.log("w", "Keyring failed: {}".format(e))
|
||||
else:
|
||||
setattr(instance, self._name, value)
|
||||
|
||||
def __set_name__(self, owner: type, name: str):
|
||||
self._name = "_{}".format(name)
|
||||
self._keyring_name = name
|
||||
self._store_secure = False
|
||||
try:
|
||||
self._store_secure = KeyringBackend.viable
|
||||
except NoKeyringError:
|
||||
Logger.logException("w", "Could not use keyring")
|
||||
setattr(owner, self._name, None)
|
|
@ -1,6 +1,6 @@
|
|||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import sys
|
||||
import threading
|
||||
from typing import Any, Callable, Optional, TYPE_CHECKING
|
||||
|
||||
|
@ -20,18 +20,23 @@ if TYPE_CHECKING:
|
|||
|
||||
|
||||
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",
|
||||
auth_state_changed_callback: Callable[["AuthenticationResponse"], Any],
|
||||
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_thread = None # type: Optional[threading.Thread]
|
||||
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._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:
|
||||
"""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 the server is already running (because of a previously aborted auth flow), we don't have to start it.
|
||||
# We still inject the new verification code though.
|
||||
Logger.log("d", "Auth web server was already running. Updating the verification code")
|
||||
self._web_server.setVerificationCode(verification_code)
|
||||
return
|
||||
|
||||
|
@ -63,18 +72,39 @@ class LocalAuthorizationServer:
|
|||
self._web_server.setState(state)
|
||||
|
||||
# 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()
|
||||
|
||||
## Stops the web server if it was running. It also does some cleanup.
|
||||
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...")
|
||||
|
||||
if self._web_server:
|
||||
try:
|
||||
self._web_server.server_close()
|
||||
self._web_server.shutdown()
|
||||
except OSError:
|
||||
# OS error can happen if the socket was already closed. We really don't care about that case.
|
||||
pass
|
||||
Logger.log("d", "Local oauth2 web server was shut down")
|
||||
self._web_server = None
|
||||
self._web_server_thread = None
|
||||
|
||||
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()
|
||||
|
|
|
@ -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.
|
||||
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:
|
||||
|
@ -8,8 +10,9 @@ class BaseModel:
|
|||
self.__dict__.update(kwargs)
|
||||
|
||||
|
||||
## OAuth OAuth2Settings data template.
|
||||
class OAuth2Settings(BaseModel):
|
||||
"""OAuth OAuth2Settings data template."""
|
||||
|
||||
CALLBACK_PORT = None # type: Optional[int]
|
||||
OAUTH_SERVER_URL = 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
|
||||
|
||||
|
||||
## User profile data template.
|
||||
class UserProfile(BaseModel):
|
||||
"""User profile data template."""
|
||||
|
||||
user_id = None # type: Optional[str]
|
||||
username = 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):
|
||||
"""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
|
||||
token_type = None # type: Optional[str]
|
||||
access_token = None # type: Optional[str]
|
||||
refresh_token = None # type: Optional[str]
|
||||
expires_in = None # type: Optional[str]
|
||||
scope = None # type: Optional[str]
|
||||
err_message = None # type: Optional[str]
|
||||
received_at = None # type: Optional[str]
|
||||
access_token = KeyringAttribute()
|
||||
refresh_token = KeyringAttribute()
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self.access_token = kwargs.pop("access_token", None)
|
||||
self.refresh_token = kwargs.pop("refresh_token", None)
|
||||
super(AuthenticationResponse, self).__init__(**kwargs)
|
||||
|
||||
def dump(self) -> Dict[str, Union[bool, Optional[str]]]:
|
||||
"""
|
||||
Dumps the dictionary of Authentication attributes. KeyringAttributes are transformed to public attributes
|
||||
If the keyring was used, these will have a None value, otherwise they will have the secret value
|
||||
|
||||
:return: Dictionary of Authentication attributes
|
||||
"""
|
||||
dumped = deepcopy(vars(self))
|
||||
dumped["access_token"] = dumped.pop("_access_token")
|
||||
dumped["refresh_token"] = dumped.pop("_refresh_token")
|
||||
return dumped
|
||||
|
||||
|
||||
## Response status template.
|
||||
class ResponseStatus(BaseModel):
|
||||
"""Response status template."""
|
||||
|
||||
code = 200 # type: int
|
||||
message = "" # type: str
|
||||
|
||||
|
||||
## Response data template.
|
||||
class ResponseData(BaseModel):
|
||||
"""Response data template."""
|
||||
|
||||
status = None # type: ResponseStatus
|
||||
data_stream = None # type: Optional[bytes]
|
||||
redirect_uri = None # type: Optional[str]
|
||||
content_type = "text/html" # type: str
|
||||
|
||||
|
||||
## Possible HTTP responses.
|
||||
HTTP_STATUS = {
|
||||
"""Possible HTTP responses."""
|
||||
|
||||
"OK": ResponseStatus(code = 200, message = "OK"),
|
||||
"NOT_FOUND": ResponseStatus(code = 404, message = "NOT FOUND"),
|
||||
"REDIRECT": ResponseStatus(code = 302, message = "REDIRECT")
|
||||
|
|
|
@ -7,27 +7,33 @@ from UM.Scene.Iterator import Iterator
|
|||
from UM.Scene.SceneNode import SceneNode
|
||||
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):
|
||||
"""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:
|
||||
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._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:
|
||||
"""Fills the ``_node_stack`` with a list of scene nodes that need to be printed in order. """
|
||||
|
||||
node_list = []
|
||||
for node in self._scene_node.getChildren():
|
||||
if not issubclass(type(node), SceneNode):
|
||||
continue
|
||||
|
||||
# Node can't be printed, so don't bother sending it.
|
||||
if getattr(node, "_outside_buildarea", False):
|
||||
continue
|
||||
|
||||
if node.callDecoration("getConvexHull"):
|
||||
node_list.append(node)
|
||||
|
||||
|
||||
if len(node_list) < 2:
|
||||
self._node_stack = node_list[:]
|
||||
return
|
||||
|
@ -35,8 +41,8 @@ class OneAtATimeIterator(Iterator.Iterator):
|
|||
# Copy the list
|
||||
self._original_node_list = node_list[:]
|
||||
|
||||
## 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]
|
||||
# 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]
|
||||
|
||||
# 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)):
|
||||
|
@ -75,10 +81,14 @@ class OneAtATimeIterator(Iterator.Iterator):
|
|||
return True
|
||||
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:
|
||||
"""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)
|
||||
for other_node in other_nodes:
|
||||
other_node_index = self._original_node_list.index(other_node)
|
||||
|
@ -86,14 +96,26 @@ class OneAtATimeIterator(Iterator.Iterator):
|
|||
return True
|
||||
return False
|
||||
|
||||
## Calculate score simply sums the number of other objects it 'blocks'
|
||||
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_b = sum(self._hit_map[self._original_node_list.index(b)])
|
||||
return score_a - score_b
|
||||
|
||||
## Checks if A can be printed before B
|
||||
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:
|
||||
return False
|
||||
|
||||
|
@ -116,12 +138,14 @@ class OneAtATimeIterator(Iterator.Iterator):
|
|||
return False
|
||||
|
||||
|
||||
## Internal object used to keep track of a possible order in which to print objects.
|
||||
class _ObjectOrder:
|
||||
## 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.
|
||||
"""Internal object used to keep track of a possible order in which to print objects."""
|
||||
|
||||
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.todo = todo
|
||||
|
|
|
@ -6,8 +6,9 @@ from UM.Operations.GroupedOperation import GroupedOperation
|
|||
from UM.Scene.SceneNode import SceneNode
|
||||
|
||||
|
||||
## A specialised operation designed specifically to modify the previous operation.
|
||||
class PlatformPhysicsOperation(Operation):
|
||||
"""A specialised operation designed specifically to modify the previous operation."""
|
||||
|
||||
def __init__(self, node: SceneNode, translation: Vector) -> None:
|
||||
super().__init__()
|
||||
self._node = node
|
||||
|
|
|
@ -7,8 +7,9 @@ from UM.Operations.Operation import Operation
|
|||
from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator
|
||||
|
||||
|
||||
## Simple operation to set the buildplate number of a scenenode.
|
||||
class SetBuildPlateNumberOperation(Operation):
|
||||
"""Simple operation to set the buildplate number of a scenenode."""
|
||||
|
||||
def __init__(self, node: SceneNode, build_plate_nr: int) -> None:
|
||||
super().__init__()
|
||||
self._node = node
|
||||
|
|
|
@ -5,33 +5,38 @@ from typing import Optional
|
|||
from UM.Scene.SceneNode import SceneNode
|
||||
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):
|
||||
## Initialises this SetParentOperation.
|
||||
#
|
||||
# \param node The node which will be reparented.
|
||||
# \param parent_node The node which will be the parent.
|
||||
"""An operation that parents a scene node to another scene node."""
|
||||
|
||||
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__()
|
||||
self._node = node
|
||||
self._parent = parent_node
|
||||
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:
|
||||
"""Undoes the set-parent operation, restoring the old parent."""
|
||||
|
||||
self._set_parent(self._old_parent)
|
||||
|
||||
## Re-applies the set-parent operation.
|
||||
def redo(self) -> None:
|
||||
"""Re-applies the set-parent operation."""
|
||||
|
||||
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:
|
||||
"""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:
|
||||
current_parent = self._node.getParent()
|
||||
if current_parent:
|
||||
|
@ -57,8 +62,10 @@ class SetParentOperation(Operation.Operation):
|
|||
|
||||
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:
|
||||
"""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)
|
||||
|
|
|
@ -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.
|
||||
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from UM.Qt.QtApplication import QtApplication
|
||||
from UM.Logger import Logger
|
||||
from UM.Math.Vector import Vector
|
||||
from UM.Resources import Resources
|
||||
|
||||
from UM.View.RenderPass import RenderPass
|
||||
from UM.View.GL.OpenGL import OpenGL
|
||||
from UM.View.GL.ShaderProgram import InvalidShaderProgramError
|
||||
from UM.View.RenderBatch import RenderBatch
|
||||
|
||||
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
|
||||
|
@ -16,11 +18,15 @@ from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
|
|||
if TYPE_CHECKING:
|
||||
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):
|
||||
"""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:
|
||||
super().__init__("picking", width, height)
|
||||
|
||||
|
@ -31,7 +37,11 @@ class PickingPass(RenderPass):
|
|||
|
||||
def render(self) -> None:
|
||||
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()
|
||||
self._gl.glViewport(0, 0, width, height)
|
||||
|
@ -44,14 +54,20 @@ class PickingPass(RenderPass):
|
|||
# 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.
|
||||
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()
|
||||
batch.render(self._scene.getActiveCamera())
|
||||
self.release()
|
||||
|
||||
## Get the distance in mm from the camera to at a certain pixel coordinate.
|
||||
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()
|
||||
|
||||
window_size = self._renderer.getWindowSize()
|
||||
|
@ -66,8 +82,14 @@ class PickingPass(RenderPass):
|
|||
distance = (distance & 0x00ffffff) / 1000. # drop the alpha channel and covert to mm
|
||||
return distance
|
||||
|
||||
## Get the world coordinates of a picked point
|
||||
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)
|
||||
camera = self._scene.getActiveCamera()
|
||||
if camera:
|
||||
|
|
|
@ -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.
|
||||
|
||||
from PyQt5.QtCore import QTimer
|
||||
from shapely.errors import TopologicalError # To capture errors if Shapely messes up.
|
||||
|
||||
from UM.Application import Application
|
||||
from UM.Logger import Logger
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator
|
||||
from UM.Math.Vector import Vector
|
||||
|
@ -93,15 +95,15 @@ class PlatformPhysics:
|
|||
# Ignore root, ourselves and anything that is not a normal SceneNode.
|
||||
if other_node is root or not issubclass(type(other_node), SceneNode) or other_node is node or other_node.callDecoration("getBuildPlateNumber") != node.callDecoration("getBuildPlateNumber"):
|
||||
continue
|
||||
|
||||
|
||||
# Ignore collisions of a group with it's own children
|
||||
if other_node in node.getAllChildren() or node in other_node.getAllChildren():
|
||||
continue
|
||||
|
||||
|
||||
# Ignore collisions within a group
|
||||
if other_node.getParent() and node.getParent() and (other_node.getParent().callDecoration("isGroup") is not None or node.getParent().callDecoration("isGroup") is not None):
|
||||
continue
|
||||
|
||||
|
||||
# Ignore nodes that do not have the right properties set.
|
||||
if not other_node.callDecoration("getConvexHull") or not other_node.getBoundingBox():
|
||||
continue
|
||||
|
@ -136,7 +138,11 @@ class PlatformPhysics:
|
|||
own_convex_hull = node.callDecoration("getConvexHull")
|
||||
other_convex_hull = other_node.callDecoration("getConvexHull")
|
||||
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!
|
||||
temp_move_vector = move_vector.set(x = move_vector.x + overlap[0] * self._move_factor,
|
||||
z = move_vector.z + overlap[1] * self._move_factor)
|
||||
|
@ -175,7 +181,7 @@ class PlatformPhysics:
|
|||
|
||||
if tool.getPluginId() == "TranslateTool":
|
||||
for node in Selection.getAllSelectedObjects():
|
||||
if node.getBoundingBox().bottom < 0:
|
||||
if node.getBoundingBox() and node.getBoundingBox().bottom < 0:
|
||||
if not node.getDecorator(ZOffsetDecorator.ZOffsetDecorator):
|
||||
node.addDecorator(ZOffsetDecorator.ZOffsetDecorator())
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
from typing import Optional, TYPE_CHECKING, cast
|
||||
from typing import Optional, TYPE_CHECKING, cast, List
|
||||
|
||||
|
||||
from UM.Application import Application
|
||||
from UM.Logger import Logger
|
||||
from UM.Resources import Resources
|
||||
|
||||
from UM.View.RenderPass import RenderPass
|
||||
|
@ -20,9 +21,14 @@ if TYPE_CHECKING:
|
|||
from UM.Scene.Camera import Camera
|
||||
|
||||
|
||||
# Make color brighter by normalizing it (maximum factor 2.5 brighter)
|
||||
# color_list is a list of 4 elements: [r, g, b, a], each element is a float 0..1
|
||||
def prettier_color(color_list):
|
||||
def prettier_color(color_list: List[float]) -> List[float]:
|
||||
"""Make color brighter by normalizing
|
||||
|
||||
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])
|
||||
if maximum > 0:
|
||||
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]
|
||||
|
||||
|
||||
## 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):
|
||||
"""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:
|
||||
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_specularColor", [0.6, 0.6, 0.6, 1.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.
|
||||
else:
|
||||
Logger.error("Unable to compile shader program: overhang.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:
|
||||
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_opacity", 0.6)
|
||||
|
||||
|
@ -102,12 +114,12 @@ class PreviewPass(RenderPass):
|
|||
1.0]
|
||||
uniforms["diffuse_color"] = prettier_color(diffuse_color)
|
||||
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:
|
||||
# Normal scene node
|
||||
uniforms = {}
|
||||
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()
|
||||
|
||||
|
|
|
@ -10,8 +10,14 @@ class PrintJobPreviewImageProvider(QQuickImageProvider):
|
|||
def __init__(self):
|
||||
super().__init__(QQuickImageProvider.Image)
|
||||
|
||||
## Request a new image.
|
||||
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
|
||||
# increment, we need to strip that first.
|
||||
uuid = id[id.find("/") + 1:]
|
||||
|
|
|
@ -7,6 +7,8 @@ from enum import IntEnum
|
|||
from threading import Thread
|
||||
from typing import Union
|
||||
|
||||
from UM.Logger import Logger
|
||||
|
||||
MYPY = False
|
||||
if MYPY:
|
||||
from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
|
||||
|
@ -33,15 +35,22 @@ class FirmwareUpdater(QObject):
|
|||
else:
|
||||
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:
|
||||
raise NotImplementedError("_updateFirmware needs to be implemented")
|
||||
|
||||
## Cleanup after a succesful update
|
||||
def _cleanupAfterUpdate(self) -> None:
|
||||
"""Cleanup after a succesful update"""
|
||||
|
||||
# Clean up for next attempt.
|
||||
self._update_firmware_thread = Thread(target=self._updateFirmware, daemon=True, name = "FirmwareUpdateThread")
|
||||
self._firmware_file = ""
|
||||
|
|
|
@ -47,10 +47,13 @@ class ExtruderConfigurationModel(QObject):
|
|||
def hotendID(self) -> Optional[str]:
|
||||
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:
|
||||
"""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
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
@ -71,11 +74,11 @@ class ExtruderConfigurationModel(QObject):
|
|||
# Empty materials should be ignored for comparison
|
||||
if self.activeMaterial is not None and other.activeMaterial is not None:
|
||||
if self.activeMaterial.guid != other.activeMaterial.guid:
|
||||
if self.activeMaterial.guid != "" and other.activeMaterial.guid != "":
|
||||
return False
|
||||
else:
|
||||
if self.activeMaterial.guid == "" and other.activeMaterial.guid == "":
|
||||
# At this point there is no material, so it doesn't matter what the hotend is.
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
if self.hotendID != other.hotendID:
|
||||
return False
|
||||
|
|
|
@ -54,8 +54,9 @@ class ExtruderOutputModel(QObject):
|
|||
def updateActiveMaterial(self, material: Optional["MaterialOutputModel"]) -> None:
|
||||
self._extruder_configuration.setMaterial(material)
|
||||
|
||||
## Update the hotend temperature. This only changes it locally.
|
||||
def updateHotendTemperature(self, temperature: float) -> None:
|
||||
"""Update the hotend temperature. This only changes it locally."""
|
||||
|
||||
if self._hotend_temperature != temperature:
|
||||
self._hotend_temperature = temperature
|
||||
self.hotendTemperatureChanged.emit()
|
||||
|
@ -65,9 +66,10 @@ class ExtruderOutputModel(QObject):
|
|||
self._target_hotend_temperature = temperature
|
||||
self.targetHotendTemperatureChanged.emit()
|
||||
|
||||
## Set the target hotend temperature. This ensures that it's actually sent to the remote.
|
||||
@pyqtSlot(float)
|
||||
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.updateTargetHotendTemperature(temperature)
|
||||
|
||||
|
@ -97,17 +99,19 @@ class ExtruderOutputModel(QObject):
|
|||
self._is_preheating = pre_heating
|
||||
self.isPreheatingChanged.emit()
|
||||
|
||||
@pyqtProperty(bool, notify=isPreheatingChanged)
|
||||
@pyqtProperty(bool, notify = isPreheatingChanged)
|
||||
def isPreheating(self) -> bool:
|
||||
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)
|
||||
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)
|
||||
|
||||
@pyqtSlot()
|
||||
|
|
|
@ -48,9 +48,11 @@ class PrinterConfigurationModel(QObject):
|
|||
def buildplateConfiguration(self) -> str:
|
||||
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:
|
||||
"""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:
|
||||
return False
|
||||
for configuration in self._extruder_configurations:
|
||||
|
@ -97,9 +99,11 @@ class PrinterConfigurationModel(QObject):
|
|||
|
||||
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):
|
||||
"""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)
|
||||
first_extruder = None
|
||||
for configuration in self._extruder_configurations:
|
||||
|
|
|
@ -163,13 +163,15 @@ class PrinterOutputModel(QObject):
|
|||
def moveHead(self, x: float = 0, y: float = 0, z: float = 0, speed: float = 3000) -> None:
|
||||
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)
|
||||
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)
|
||||
|
||||
@pyqtSlot()
|
||||
|
@ -200,8 +202,9 @@ class PrinterOutputModel(QObject):
|
|||
self._unique_name = unique_name
|
||||
self.nameChanged.emit()
|
||||
|
||||
## Update the bed temperature. This only changes it locally.
|
||||
def updateBedTemperature(self, temperature: float) -> None:
|
||||
"""Update the bed temperature. This only changes it locally."""
|
||||
|
||||
if self._bed_temperature != temperature:
|
||||
self._bed_temperature = temperature
|
||||
self.bedTemperatureChanged.emit()
|
||||
|
@ -211,9 +214,10 @@ class PrinterOutputModel(QObject):
|
|||
self._target_bed_temperature = temperature
|
||||
self.targetBedTemperatureChanged.emit()
|
||||
|
||||
## Set the target bed temperature. This ensures that it's actually sent to the remote.
|
||||
@pyqtSlot(float)
|
||||
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.updateTargetBedTemperature(temperature)
|
||||
|
||||
|
|
|
@ -32,8 +32,9 @@ class NetworkMJPGImage(QQuickPaintedItem):
|
|||
|
||||
self.setAntialiasing(True)
|
||||
|
||||
## Ensure that close gets called when object is destroyed
|
||||
def __del__(self) -> None:
|
||||
"""Ensure that close gets called when object is destroyed"""
|
||||
|
||||
self.stop()
|
||||
|
||||
|
||||
|
@ -111,7 +112,7 @@ class NetworkMJPGImage(QQuickPaintedItem):
|
|||
|
||||
if not self._image_reply.isFinished():
|
||||
self._image_reply.close()
|
||||
except Exception as e: # RuntimeError
|
||||
except Exception: # RuntimeError
|
||||
pass # It can happen that the wrapped c++ object is already deleted.
|
||||
|
||||
self._image_reply = None
|
||||
|
|
|
@ -84,8 +84,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
|||
def _compressGCode(self) -> Optional[bytes]:
|
||||
self._compressing_gcode = True
|
||||
|
||||
## Mash the data into single string
|
||||
max_chars_per_line = int(1024 * 1024 / 4) # 1/4 MB per line.
|
||||
"""Mash the data into single string"""
|
||||
file_data_bytes_list = []
|
||||
batched_lines = []
|
||||
batched_lines_count = 0
|
||||
|
@ -145,9 +145,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
|||
request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent)
|
||||
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:
|
||||
"""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)
|
||||
|
||||
def _createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
|
||||
|
@ -163,8 +165,9 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
|||
part.setBody(data)
|
||||
return part
|
||||
|
||||
## Convenience function to get the username, either from the cloud or from the OS.
|
||||
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
|
||||
account = CuraApplication.getInstance().getCuraAPI().account # type: Account
|
||||
if account and account.isLoggedIn:
|
||||
|
@ -187,15 +190,17 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
|||
self._createNetworkManager()
|
||||
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",
|
||||
on_finished: Optional[Callable[[QNetworkReply], 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()
|
||||
|
||||
request = self._createEmptyRequest(url, content_type = content_type)
|
||||
|
@ -212,10 +217,12 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
|||
if on_progress is not None:
|
||||
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:
|
||||
"""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()
|
||||
|
||||
request = self._createEmptyRequest(url)
|
||||
|
@ -228,10 +235,12 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
|||
reply = self._manager.deleteResource(request)
|
||||
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:
|
||||
"""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()
|
||||
|
||||
request = self._createEmptyRequest(url)
|
||||
|
@ -244,14 +253,18 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
|||
reply = self._manager.get(request)
|
||||
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],
|
||||
on_finished: Optional[Callable[[QNetworkReply], 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()
|
||||
|
||||
request = self._createEmptyRequest(url)
|
||||
|
@ -318,10 +331,13 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
|||
if on_finished is not None:
|
||||
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:
|
||||
"""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()
|
||||
active_machine_network_name = CuraApplication.getInstance().getMachineManager().activeMachineNetworkKey()
|
||||
if global_container_stack and device_id == active_machine_network_name:
|
||||
|
@ -366,32 +382,38 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
|||
def getProperties(self):
|
||||
return self._properties
|
||||
|
||||
## Get the unique key of this machine
|
||||
# \return key String containing the key of the machine.
|
||||
@pyqtProperty(str, constant = True)
|
||||
def key(self) -> str:
|
||||
"""Get the unique key of this machine
|
||||
|
||||
:return: key String containing the key of the machine.
|
||||
"""
|
||||
return self._id
|
||||
|
||||
## The IP address of the printer.
|
||||
@pyqtProperty(str, constant = True)
|
||||
def address(self) -> str:
|
||||
"""The IP address of the printer."""
|
||||
|
||||
return self._properties.get(b"address", b"").decode("utf-8")
|
||||
|
||||
## Name of the printer (as returned from the ZeroConf properties)
|
||||
@pyqtProperty(str, constant = True)
|
||||
def name(self) -> str:
|
||||
"""Name of the printer (as returned from the ZeroConf properties)"""
|
||||
|
||||
return self._properties.get(b"name", b"").decode("utf-8")
|
||||
|
||||
## Firmware version (as returned from the ZeroConf properties)
|
||||
@pyqtProperty(str, constant = True)
|
||||
def firmwareVersion(self) -> str:
|
||||
"""Firmware version (as returned from the ZeroConf properties)"""
|
||||
|
||||
return self._properties.get(b"firmware_version", b"").decode("utf-8")
|
||||
|
||||
@pyqtProperty(str, constant = True)
|
||||
def printerType(self) -> str:
|
||||
return self._properties.get(b"printer_type", b"Unknown").decode("utf-8")
|
||||
|
||||
## IP adress of this printer
|
||||
@pyqtProperty(str, constant = True)
|
||||
def ipAddress(self) -> str:
|
||||
"""IP adress of this printer"""
|
||||
|
||||
return self._address
|
||||
|
|
|
@ -2,15 +2,19 @@
|
|||
# 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:
|
||||
## Constructs the peripheral.
|
||||
# \param type A unique ID for the type of peripheral.
|
||||
# \param name A human-readable name for the peripheral.
|
||||
"""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.
|
||||
"""
|
||||
|
||||
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.name = name
|
||||
|
|
|
@ -24,8 +24,9 @@ if MYPY:
|
|||
i18n_catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
## The current processing state of the backend.
|
||||
class ConnectionState(IntEnum):
|
||||
"""The current processing state of the backend."""
|
||||
|
||||
Closed = 0
|
||||
Connecting = 1
|
||||
Connected = 2
|
||||
|
@ -40,17 +41,19 @@ class ConnectionType(IntEnum):
|
|||
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
|
||||
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()
|
||||
connectionStateChanged = pyqtSignal(str)
|
||||
|
@ -184,26 +187,30 @@ class PrinterOutputDevice(QObject, OutputDevice):
|
|||
if self._monitor_item is None:
|
||||
self._monitor_item = QtApplication.getInstance().createQmlComponent(self._monitor_view_qml_path, {"OutputDevice": self})
|
||||
|
||||
## Attempt to establish connection
|
||||
def connect(self) -> None:
|
||||
"""Attempt to establish connection"""
|
||||
|
||||
self.setConnectionState(ConnectionState.Connecting)
|
||||
self._update_timer.start()
|
||||
|
||||
## Attempt to close the connection
|
||||
def close(self) -> None:
|
||||
"""Attempt to close the connection"""
|
||||
|
||||
self._update_timer.stop()
|
||||
self.setConnectionState(ConnectionState.Closed)
|
||||
|
||||
## Ensure that close gets called when object is destroyed
|
||||
def __del__(self) -> None:
|
||||
"""Ensure that close gets called when object is destroyed"""
|
||||
|
||||
self.close()
|
||||
|
||||
@pyqtProperty(bool, notify = acceptsCommandsChanged)
|
||||
def acceptsCommands(self) -> bool:
|
||||
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:
|
||||
"""Set a flag to signal the UI that the printer is not (yet) ready to receive commands"""
|
||||
|
||||
if 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
|
||||
self._updateUniqueConfigurations()
|
||||
|
||||
## Set the device firmware name
|
||||
#
|
||||
# \param name The name of the firmware.
|
||||
def _setFirmwareName(self, name: str) -> None:
|
||||
"""Set the device firmware name
|
||||
|
||||
:param name: The name of the firmware.
|
||||
"""
|
||||
|
||||
self._firmware_name = name
|
||||
|
||||
## Get the name of device firmware
|
||||
#
|
||||
# This name can be used to define device type
|
||||
def getFirmwareName(self) -> Optional[str]:
|
||||
"""Get the name of device firmware
|
||||
|
||||
This name can be used to define device type
|
||||
"""
|
||||
|
||||
return self._firmware_name
|
||||
|
||||
def getFirmwareUpdater(self) -> Optional["FirmwareUpdater"]:
|
||||
|
|
|
@ -10,15 +10,19 @@ class NoProfileException(Exception):
|
|||
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):
|
||||
"""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):
|
||||
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):
|
||||
"""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.")
|
||||
|
|
|
@ -3,23 +3,29 @@
|
|||
|
||||
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):
|
||||
## Initialises the profile writer.
|
||||
#
|
||||
# This currently doesn't do anything since the writer is basically static.
|
||||
"""Base class for profile writer plugins.
|
||||
|
||||
This class defines a write() function to write profiles to files with.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialises the profile writer.
|
||||
|
||||
This currently doesn't do anything since the writer is basically static.
|
||||
"""
|
||||
|
||||
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):
|
||||
"""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.")
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue