mirror of
https://github.com/Ultimaker/Cura.git
synced 2025-08-06 13:34:01 -06:00
Merge pull request #4 from Ultimaker/master
Merge With recent Ultimaker Code
This commit is contained in:
commit
015af88754
4174 changed files with 387532 additions and 484968 deletions
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.)
|
68
.github/ISSUE_TEMPLATE/bugreport.yaml
vendored
Normal file
68
.github/ISSUE_TEMPLATE/bugreport.yaml
vendored
Normal file
|
@ -0,0 +1,68 @@
|
|||
name: Bug Report
|
||||
description: Create a report to help us fix issues.
|
||||
labels: "Type: Bug"
|
||||
issue_body: true
|
||||
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.8.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: |
|
||||
## Additional information & file uploads
|
||||
|
||||
Please be sure to add the following files:
|
||||
* For slicing issues, upload a **project file** that clearly shows the bug.
|
||||
To save a project file go to `File -> Save project`. Please make sure to .zip your project file. For big files you may need to use WeTransfer or similar file sharing sites.
|
||||
G-code files are not project files!
|
||||
* **Screenshots** of showing the problem, perhaps before/after images.
|
||||
* A **log file**, see [here](https://github.com/Ultimaker/Cura#logging-issues) how to find the log file.
|
||||
|
||||
You can add these files and additional information that is relevant to the issue in the comments below.
|
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.)
|
46
.github/ISSUE_TEMPLATE/featurerequest.yaml
vendored
Normal file
46
.github/ISSUE_TEMPLATE/featurerequest.yaml
vendored
Normal file
|
@ -0,0 +1,46 @@
|
|||
name: Feature Request
|
||||
description: Suggest an idea for this project.
|
||||
labels: "Type: New Feature"
|
||||
issue_body: true
|
||||
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. Again, 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: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Additional information & file uploads
|
||||
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
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -53,6 +53,7 @@ plugins/GodMode
|
|||
plugins/OctoPrintPlugin
|
||||
plugins/ProfileFlattener
|
||||
plugins/SettingsGuide
|
||||
plugins/SettingsGuide2
|
||||
plugins/SVGToolpathReader
|
||||
plugins/X3GWriter
|
||||
|
||||
|
@ -77,3 +78,5 @@ CuraEngine
|
|||
|
||||
#Prevents import failures when plugin running tests
|
||||
plugins/__init__.py
|
||||
|
||||
/venv
|
||||
|
|
|
@ -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/
|
||||
|
|
|
@ -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
|
||||
------------
|
||||
|
@ -26,7 +28,7 @@ Dependencies
|
|||
|
||||
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.
|
||||
|
||||
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,14 +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.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.i18n import i18nCatalog
|
||||
from cura.OAuth2.AuthorizationService import AuthorizationService
|
||||
from cura.OAuth2.Models import OAuth2Settings
|
||||
from cura.UltimakerCloud import UltimakerCloudAuthentication
|
||||
from cura.UltimakerCloud import UltimakerCloudConstants
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
@ -16,29 +18,61 @@ 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 = 30.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)
|
||||
|
||||
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,
|
||||
|
@ -47,7 +81,8 @@ class Account(QObject):
|
|||
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",
|
||||
"library.project.read library.project.write cura.printjob.read cura.printjob.write "
|
||||
"cura.mesh.read cura.mesh.write",
|
||||
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)
|
||||
|
@ -55,6 +90,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)
|
||||
|
@ -62,12 +107,65 @@ 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)
|
||||
|
@ -82,18 +180,60 @@ class Account(QObject):
|
|||
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.warning("Starting a new sync while previous sync was not completed\n{}", str(self._sync_services))
|
||||
|
||||
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):
|
||||
|
@ -113,15 +253,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.4.0"
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraAppName # type: ignore
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
# 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,20 +17,23 @@ 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]
|
||||
|
||||
@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)
|
||||
|
@ -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
|
||||
|
|
|
@ -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,22 @@ 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"))
|
||||
no_full_solution_message.show()
|
||||
|
||||
self.finished.emit(self)
|
||||
|
|
147
cura/Arranging/Nest2DArrange.py
Normal file
147
cura/Arranging/Nest2DArrange.py
Normal file
|
@ -0,0 +1,147 @@
|
|||
# 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
|
||||
"""
|
||||
|
||||
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: # 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: # 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, 10000, 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
|
|
@ -11,19 +11,24 @@ if TYPE_CHECKING:
|
|||
from UM.Scene.SceneNode import SceneNode
|
||||
|
||||
|
||||
## Polygon representation as an array for use with Arrange
|
||||
class ShapeArray:
|
||||
"""Polygon representation as an array for use with :py:class:`cura.Arranging.Arrange.Arrange`"""
|
||||
|
||||
def __init__(self, arr: numpy.array, offset_x: float, offset_y: float, scale: float = 1) -> None:
|
||||
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":
|
||||
"""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,14 +97,19 @@ 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:
|
||||
"""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) # 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]:
|
||||
"""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
|
||||
|
|
|
@ -31,7 +31,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:
|
||||
|
|
|
@ -18,24 +18,26 @@ 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."""
|
||||
|
||||
# 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()
|
||||
|
||||
|
@ -62,11 +64,11 @@ 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
|
||||
machine_count = max(len([s for s in files if "machine_instances/" in s]) - 1, 0) # If people delete their profiles but not their preferences, it can still make a backup, and report -1 profiles. Server crashes on this.
|
||||
material_count = max(len([s for s in files if "materials/" in s]) - 1, 0)
|
||||
profile_count = max(len([s for s in files if "quality_changes/" in s]) - 1, 0)
|
||||
plugin_count = len([s for s in files if "plugin.json" in s])
|
||||
|
||||
|
||||
# Store the archive and metadata so the BackupManager can fetch them when needed.
|
||||
self.zip_file = buffer.getvalue()
|
||||
self.meta_data = {
|
||||
|
@ -77,10 +79,13 @@ class Backup:
|
|||
"plugin_count": str(plugin_count)
|
||||
}
|
||||
|
||||
## 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]:
|
||||
"""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))
|
||||
try:
|
||||
archive = ZipFile(buffer, "w", ZIP_DEFLATED)
|
||||
|
@ -99,13 +104,17 @@ class Backup:
|
|||
"Could not create archive from user data directory: {}".format(error)))
|
||||
return None
|
||||
|
||||
## Show a UI message.
|
||||
def _showMessage(self, message: str) -> None:
|
||||
"""Show a UI message."""
|
||||
|
||||
Message(message, title=self.catalog.i18nc("@info:title", "Backup"), lifetime=30).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.")
|
||||
|
@ -139,12 +148,14 @@ class Backup:
|
|||
|
||||
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
|
||||
|
@ -158,7 +169,7 @@ class Backup:
|
|||
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")
|
||||
except (PermissionError, EnvironmentError):
|
||||
Logger.logException("e", "Unable to extract the backup due to permission or file system errors.")
|
||||
return False
|
||||
return True
|
||||
|
|
|
@ -24,6 +24,7 @@ class BackupsManager:
|
|||
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).
|
||||
"""
|
||||
|
||||
|
@ -37,6 +38,7 @@ class BackupsManager:
|
|||
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.
|
||||
"""
|
||||
|
|
|
@ -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.
|
||||
|
||||
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,6 +92,8 @@ 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"
|
||||
|
@ -105,19 +108,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 +164,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 +180,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 +242,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 +281,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 +290,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 +311,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 +344,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 +510,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 +601,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 +613,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 +740,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 +761,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 +795,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 +814,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 +850,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 +859,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,19 +898,18 @@ 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:
|
||||
|
@ -1008,13 +1040,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 +1055,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 +1087,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 +1131,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 +1174,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)
|
||||
|
@ -1131,7 +1187,7 @@ class BuildVolume(SceneNode):
|
|||
_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
|
||||
|
@ -73,18 +75,21 @@ class CuraActions(QObject):
|
|||
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 +111,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 = []
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import cast, TYPE_CHECKING, Optional, Callable, List, Any
|
||||
from typing import cast, TYPE_CHECKING, Optional, Callable, List, Any, Dict
|
||||
|
||||
import numpy
|
||||
from PyQt5.QtCore import QObject, QTimer, QUrl, pyqtSignal, pyqtProperty, QEvent, Q_ENUMS
|
||||
|
@ -30,12 +31,14 @@ from UM.Operations.SetTransformOperation import SetTransformOperation
|
|||
from UM.Platform import Platform
|
||||
from UM.PluginError import PluginNotFoundError
|
||||
from UM.Preferences import Preferences
|
||||
from UM.Qt.Bindings.FileProviderModel import FileProviderModel
|
||||
from UM.Qt.QtApplication import QtApplication # The class we're inheriting from.
|
||||
from UM.Resources import Resources
|
||||
from UM.Scene.Camera import Camera
|
||||
from UM.Scene.GroupDecorator import GroupDecorator
|
||||
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from UM.Scene.SceneNodeSettings import SceneNodeSettings
|
||||
from UM.Scene.Selection import Selection
|
||||
from UM.Scene.ToolHandle import ToolHandle
|
||||
from UM.Settings.ContainerRegistry import ContainerRegistry
|
||||
|
@ -48,14 +51,16 @@ from UM.Workspace.WorkspaceReader import WorkspaceReader
|
|||
from UM.i18n import i18nCatalog
|
||||
from cura import ApplicationMetadata
|
||||
from cura.API import CuraAPI
|
||||
from cura.API.Account import Account
|
||||
from cura.Arranging.Arrange import Arrange
|
||||
from cura.Arranging.ArrangeObjectsAllBuildPlatesJob import ArrangeObjectsAllBuildPlatesJob
|
||||
from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob
|
||||
from cura.Arranging.ShapeArray import ShapeArray
|
||||
from cura.Arranging.Nest2DArrange import arrange
|
||||
from cura.Machines.MachineErrorChecker import MachineErrorChecker
|
||||
from cura.Machines.Models.BuildPlateModel import BuildPlateModel
|
||||
from cura.Machines.Models.CustomQualityProfilesDropDownMenuModel import CustomQualityProfilesDropDownMenuModel
|
||||
from cura.Machines.Models.DiscoveredPrintersModel import DiscoveredPrintersModel
|
||||
from cura.Machines.Models.DiscoveredCloudPrintersModel import DiscoveredCloudPrintersModel
|
||||
from cura.Machines.Models.ExtrudersModel import ExtrudersModel
|
||||
from cura.Machines.Models.FavoriteMaterialsModel import FavoriteMaterialsModel
|
||||
from cura.Machines.Models.FirstStartMachineActionsModel import FirstStartMachineActionsModel
|
||||
|
@ -104,7 +109,7 @@ from cura.UI.RecommendedMode import RecommendedMode
|
|||
from cura.UI.TextManager import TextManager
|
||||
from cura.UI.WelcomePagesModel import WelcomePagesModel
|
||||
from cura.UI.WhatsNewPagesModel import WhatsNewPagesModel
|
||||
from cura.UltimakerCloud import UltimakerCloudAuthentication
|
||||
from cura.UltimakerCloud import UltimakerCloudConstants
|
||||
from cura.Utils.NetworkingUtil import NetworkingUtil
|
||||
from . import BuildVolume
|
||||
from . import CameraAnimation
|
||||
|
@ -124,7 +129,7 @@ class CuraApplication(QtApplication):
|
|||
# SettingVersion represents the set of settings available in the machine/extruder definitions.
|
||||
# You need to make sure that this version number needs to be increased if there is any non-backwards-compatible
|
||||
# changes of the settings.
|
||||
SettingVersion = 13
|
||||
SettingVersion = 16
|
||||
|
||||
Created = False
|
||||
|
||||
|
@ -201,9 +206,11 @@ class CuraApplication(QtApplication):
|
|||
self._quality_management_model = None
|
||||
|
||||
self._discovered_printer_model = DiscoveredPrintersModel(self, parent = self)
|
||||
self._discovered_cloud_printers_model = DiscoveredCloudPrintersModel(self, parent = self)
|
||||
self._first_start_machine_actions_model = None
|
||||
self._welcome_pages_model = WelcomePagesModel(self, parent = self)
|
||||
self._add_printer_pages_model = AddPrinterPagesModel(self, parent = self)
|
||||
self._add_printer_pages_model_without_cancel = AddPrinterPagesModel(self, parent = self)
|
||||
self._whats_new_pages_model = WhatsNewPagesModel(self, parent = self)
|
||||
self._text_manager = TextManager(parent = self)
|
||||
|
||||
|
@ -252,15 +259,22 @@ class CuraApplication(QtApplication):
|
|||
|
||||
@pyqtProperty(str, constant=True)
|
||||
def ultimakerCloudApiRootUrl(self) -> str:
|
||||
return UltimakerCloudAuthentication.CuraCloudAPIRoot
|
||||
return UltimakerCloudConstants.CuraCloudAPIRoot
|
||||
|
||||
@pyqtProperty(str, constant = True)
|
||||
def ultimakerCloudAccountRootUrl(self) -> str:
|
||||
return UltimakerCloudAuthentication.CuraCloudAccountAPIRoot
|
||||
return UltimakerCloudConstants.CuraCloudAccountAPIRoot
|
||||
|
||||
@pyqtProperty(str, constant=True)
|
||||
def ultimakerDigitalFactoryUrl(self) -> str:
|
||||
return UltimakerCloudConstants.CuraDigitalFactoryURL
|
||||
|
||||
# Adds command line options to the command line parser. This should be called after the application is created and
|
||||
# before the pre-start.
|
||||
def addCommandLineOptions(self):
|
||||
"""Adds command line options to the command line parser.
|
||||
|
||||
This should be called after the application is created and before the pre-start.
|
||||
"""
|
||||
|
||||
super().addCommandLineOptions()
|
||||
self._cli_parser.add_argument("--help", "-h",
|
||||
action = "store_true",
|
||||
|
@ -302,6 +316,9 @@ class CuraApplication(QtApplication):
|
|||
|
||||
super().initialize()
|
||||
|
||||
self._preferences.addPreference("cura/single_instance", False)
|
||||
self._use_single_instance = self._preferences.getValue("cura/single_instance")
|
||||
|
||||
self.__sendCommandToSingleInstance()
|
||||
self._initializeSettingDefinitions()
|
||||
self._initializeSettingFunctions()
|
||||
|
@ -322,8 +339,9 @@ class CuraApplication(QtApplication):
|
|||
Logger.log("i", "Single instance commands were sent, exiting")
|
||||
sys.exit(0)
|
||||
|
||||
# Adds expected directory names and search paths for Resources.
|
||||
def __addExpectedResourceDirsAndSearchPaths(self):
|
||||
"""Adds expected directory names and search paths for Resources."""
|
||||
|
||||
# this list of dir names will be used by UM to detect an old cura directory
|
||||
for dir_name in ["extruders", "machine_instances", "materials", "plugins", "quality", "quality_changes", "user", "variants", "intent"]:
|
||||
Resources.addExpectedDirNameInData(dir_name)
|
||||
|
@ -365,9 +383,12 @@ class CuraApplication(QtApplication):
|
|||
SettingDefinition.addSettingType("[int]", None, str, None)
|
||||
|
||||
|
||||
# Adds custom property types, settings types, and extra operators (functions) that need to be registered in
|
||||
# SettingDefinition and SettingFunction.
|
||||
def _initializeSettingFunctions(self):
|
||||
"""Adds custom property types, settings types, and extra operators (functions).
|
||||
|
||||
Whom need to be registered in SettingDefinition and SettingFunction.
|
||||
"""
|
||||
|
||||
self._cura_formula_functions = CuraFormulaFunctions(self)
|
||||
|
||||
SettingFunction.registerOperator("extruderValue", self._cura_formula_functions.getValueInExtruder)
|
||||
|
@ -377,8 +398,9 @@ class CuraApplication(QtApplication):
|
|||
SettingFunction.registerOperator("valueFromContainer", self._cura_formula_functions.getValueFromContainerAtIndex)
|
||||
SettingFunction.registerOperator("extruderValueFromContainer", self._cura_formula_functions.getValueFromContainerAtIndexInExtruder)
|
||||
|
||||
# Adds all resources and container related resources.
|
||||
def __addAllResourcesAndContainerResources(self) -> None:
|
||||
"""Adds all resources and container related resources."""
|
||||
|
||||
Resources.addStorageType(self.ResourceTypes.QualityInstanceContainer, "quality")
|
||||
Resources.addStorageType(self.ResourceTypes.QualityChangesInstanceContainer, "quality_changes")
|
||||
Resources.addStorageType(self.ResourceTypes.VariantInstanceContainer, "variants")
|
||||
|
@ -403,8 +425,9 @@ class CuraApplication(QtApplication):
|
|||
Resources.addType(self.ResourceTypes.QmlFiles, "qml")
|
||||
Resources.addType(self.ResourceTypes.Firmware, "firmware")
|
||||
|
||||
# Adds all empty containers.
|
||||
def __addAllEmptyContainers(self) -> None:
|
||||
"""Adds all empty containers."""
|
||||
|
||||
# Add empty variant, material and quality containers.
|
||||
# Since they are empty, they should never be serialized and instead just programmatically created.
|
||||
# We need them to simplify the switching between materials.
|
||||
|
@ -429,9 +452,10 @@ class CuraApplication(QtApplication):
|
|||
self._container_registry.addContainer(cura.Settings.cura_empty_instance_containers.empty_quality_changes_container)
|
||||
self.empty_quality_changes_container = cura.Settings.cura_empty_instance_containers.empty_quality_changes_container
|
||||
|
||||
# Initializes the version upgrade manager with by providing the paths for each resource type and the latest
|
||||
# versions.
|
||||
def __setLatestResouceVersionsForVersionUpgrade(self):
|
||||
"""Initializes the version upgrade manager with by providing the paths for each resource type and the latest
|
||||
versions. """
|
||||
|
||||
self._version_upgrade_manager.setCurrentVersions(
|
||||
{
|
||||
("quality", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.QualityInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
|
@ -446,12 +470,16 @@ class CuraApplication(QtApplication):
|
|||
}
|
||||
)
|
||||
|
||||
# Runs preparations that needs to be done before the starting process.
|
||||
def startSplashWindowPhase(self) -> None:
|
||||
"""Runs preparations that needs to be done before the starting process."""
|
||||
|
||||
super().startSplashWindowPhase()
|
||||
|
||||
if not self.getIsHeadLess():
|
||||
self.setWindowIcon(QIcon(Resources.getPath(Resources.Images, "cura-icon.png")))
|
||||
try:
|
||||
self.setWindowIcon(QIcon(Resources.getPath(Resources.Images, "cura-icon.png")))
|
||||
except FileNotFoundError:
|
||||
Logger.log("w", "Unable to find the window icon.")
|
||||
|
||||
self.setRequiredPlugins([
|
||||
# Misc.:
|
||||
|
@ -503,7 +531,7 @@ class CuraApplication(QtApplication):
|
|||
# Set the setting version for Preferences
|
||||
preferences = self.getPreferences()
|
||||
preferences.addPreference("metadata/setting_version", 0)
|
||||
preferences.setValue("metadata/setting_version", self.SettingVersion) #Don't make it equal to the default so that the setting version always gets written to the file.
|
||||
preferences.setValue("metadata/setting_version", self.SettingVersion) # Don't make it equal to the default so that the setting version always gets written to the file.
|
||||
|
||||
preferences.addPreference("cura/active_mode", "simple")
|
||||
|
||||
|
@ -607,12 +635,13 @@ class CuraApplication(QtApplication):
|
|||
def callConfirmExitDialogCallback(self, yes_or_no: bool) -> None:
|
||||
self._confirm_exit_dialog_callback(yes_or_no)
|
||||
|
||||
## Signal to connect preferences action in QML
|
||||
showPreferencesWindow = pyqtSignal()
|
||||
"""Signal to connect preferences action in QML"""
|
||||
|
||||
## Show the preferences window
|
||||
@pyqtSlot()
|
||||
def showPreferences(self) -> None:
|
||||
"""Show the preferences window"""
|
||||
|
||||
self.showPreferencesWindow.emit()
|
||||
|
||||
# This is called by drag-and-dropping curapackage files.
|
||||
|
@ -626,14 +655,13 @@ class CuraApplication(QtApplication):
|
|||
return self._global_container_stack
|
||||
|
||||
@override(Application)
|
||||
def setGlobalContainerStack(self, stack: "GlobalStack") -> None:
|
||||
def setGlobalContainerStack(self, stack: Optional["GlobalStack"]) -> None:
|
||||
self._setLoadingHint(self._i18n_catalog.i18nc("@info:progress", "Initializing Active Machine..."))
|
||||
super().setGlobalContainerStack(stack)
|
||||
|
||||
## A reusable dialogbox
|
||||
#
|
||||
showMessageBox = pyqtSignal(str,str, str, str, int, int,
|
||||
arguments = ["title", "text", "informativeText", "detailedText","buttons", "icon"])
|
||||
"""A reusable dialogbox"""
|
||||
|
||||
def messageBox(self, title, text,
|
||||
informativeText = "",
|
||||
|
@ -711,9 +739,12 @@ class CuraApplication(QtApplication):
|
|||
def setDefaultPath(self, key, default_path):
|
||||
self.getPreferences().setValue("local_file/%s" % key, QUrl(default_path).toLocalFile())
|
||||
|
||||
## Handle loading of all plugin types (and the backend explicitly)
|
||||
# \sa PluginRegistry
|
||||
def _loadPlugins(self) -> None:
|
||||
"""Handle loading of all plugin types (and the backend explicitly)
|
||||
|
||||
:py:class:`Uranium.UM.PluginRegistry`
|
||||
"""
|
||||
|
||||
self._plugin_registry.setCheckIfTrusted(ApplicationMetadata.IsEnterpriseVersion)
|
||||
|
||||
self._plugin_registry.addType("profile_reader", self._addProfileReader)
|
||||
|
@ -727,8 +758,7 @@ class CuraApplication(QtApplication):
|
|||
self._plugin_registry.addPluginLocation(os.path.join(QtApplication.getInstallPrefix(), "lib" + suffix, "cura"))
|
||||
if not hasattr(sys, "frozen"):
|
||||
self._plugin_registry.addPluginLocation(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "plugins"))
|
||||
self._plugin_registry.loadPlugin("ConsoleLogger")
|
||||
self._plugin_registry.loadPlugin("CuraEngineBackend")
|
||||
self._plugin_registry.preloaded_plugins.append("ConsoleLogger")
|
||||
|
||||
self._plugin_registry.loadPlugins()
|
||||
|
||||
|
@ -737,9 +767,12 @@ class CuraApplication(QtApplication):
|
|||
|
||||
self._plugins_loaded = True
|
||||
|
||||
## Set a short, user-friendly hint about current loading status.
|
||||
# The way this message is displayed depends on application state
|
||||
def _setLoadingHint(self, hint: str):
|
||||
"""Set a short, user-friendly hint about current loading status.
|
||||
|
||||
The way this message is displayed depends on application state
|
||||
"""
|
||||
|
||||
if self.started:
|
||||
Logger.info(hint)
|
||||
else:
|
||||
|
@ -771,6 +804,8 @@ class CuraApplication(QtApplication):
|
|||
self._setLoadingHint(self._i18n_catalog.i18nc("@info:progress", "Initializing build volume..."))
|
||||
root = self.getController().getScene().getRoot()
|
||||
self._volume = BuildVolume.BuildVolume(self, root)
|
||||
|
||||
# Ensure that the old style arranger still works.
|
||||
Arrange.build_volume = self._volume
|
||||
|
||||
# initialize info objects
|
||||
|
@ -786,8 +821,12 @@ class CuraApplication(QtApplication):
|
|||
self._output_device_manager.start()
|
||||
self._welcome_pages_model.initialize()
|
||||
self._add_printer_pages_model.initialize()
|
||||
self._add_printer_pages_model_without_cancel.initialize(cancellable = False)
|
||||
self._whats_new_pages_model.initialize()
|
||||
|
||||
# Initialize the FileProviderModel
|
||||
self._file_provider_model.initialize(self._onFileProviderEnabledChanged)
|
||||
|
||||
# Detect in which mode to run and execute that mode
|
||||
if self._is_headless:
|
||||
self.runWithoutGUI()
|
||||
|
@ -823,13 +862,16 @@ class CuraApplication(QtApplication):
|
|||
self.callLater(self._openFile, file_name)
|
||||
|
||||
initializationFinished = pyqtSignal()
|
||||
showAddPrintersUncancellableDialog = pyqtSignal() # Used to show the add printers dialog with a greyed background
|
||||
|
||||
## Run Cura without GUI elements and interaction (server mode).
|
||||
def runWithoutGUI(self):
|
||||
"""Run Cura without GUI elements and interaction (server mode)."""
|
||||
|
||||
self.closeSplash()
|
||||
|
||||
## Run Cura with GUI (desktop mode).
|
||||
def runWithGUI(self):
|
||||
"""Run Cura with GUI (desktop mode)."""
|
||||
|
||||
self._setLoadingHint(self._i18n_catalog.i18nc("@info:progress", "Setting up scene..."))
|
||||
|
||||
controller = self.getController()
|
||||
|
@ -852,14 +894,15 @@ class CuraApplication(QtApplication):
|
|||
diagonal = self.getBuildVolume().getDiagonalSize()
|
||||
if diagonal < 1: #No printer added yet. Set a default camera distance for normal-sized printers.
|
||||
diagonal = 375
|
||||
camera.setPosition(Vector(-80, 250, 700) * diagonal / 375)
|
||||
camera.setPosition(Vector(-80, 180, 700) * diagonal / 375)
|
||||
camera.lookAt(Vector(0, 0, 0))
|
||||
controller.getScene().setActiveCamera("3d")
|
||||
|
||||
# Initialize camera tool
|
||||
camera_tool = controller.getTool("CameraTool")
|
||||
camera_tool.setOrigin(Vector(0, 100, 0))
|
||||
camera_tool.setZoomRange(0.1, 2000)
|
||||
if camera_tool:
|
||||
camera_tool.setOrigin(Vector(0, 30, 0))
|
||||
camera_tool.setZoomRange(0.1, 2000)
|
||||
|
||||
# Initialize camera animations
|
||||
self._camera_animation = CameraAnimation.CameraAnimation()
|
||||
|
@ -886,6 +929,10 @@ class CuraApplication(QtApplication):
|
|||
def getDiscoveredPrintersModel(self, *args) -> "DiscoveredPrintersModel":
|
||||
return self._discovered_printer_model
|
||||
|
||||
@pyqtSlot(result=QObject)
|
||||
def getDiscoveredCloudPrintersModel(self, *args) -> "DiscoveredCloudPrintersModel":
|
||||
return self._discovered_cloud_printers_model
|
||||
|
||||
@pyqtSlot(result = QObject)
|
||||
def getFirstStartMachineActionsModel(self, *args) -> "FirstStartMachineActionsModel":
|
||||
if self._first_start_machine_actions_model is None:
|
||||
|
@ -906,6 +953,10 @@ class CuraApplication(QtApplication):
|
|||
def getAddPrinterPagesModel(self, *args) -> "AddPrinterPagesModel":
|
||||
return self._add_printer_pages_model
|
||||
|
||||
@pyqtSlot(result = QObject)
|
||||
def getAddPrinterPagesModelWithoutCancel(self, *args) -> "AddPrinterPagesModel":
|
||||
return self._add_printer_pages_model_without_cancel
|
||||
|
||||
@pyqtSlot(result = QObject)
|
||||
def getWhatsNewPagesModel(self, *args) -> "WhatsNewPagesModel":
|
||||
return self._whats_new_pages_model
|
||||
|
@ -979,10 +1030,13 @@ class CuraApplication(QtApplication):
|
|||
self._setting_inheritance_manager = SettingInheritanceManager.createSettingInheritanceManager()
|
||||
return self._setting_inheritance_manager
|
||||
|
||||
## Get the machine action manager
|
||||
# We ignore any *args given to this, as we also register the machine manager as qml singleton.
|
||||
# It wants to give this function an engine and script engine, but we don't care about that.
|
||||
def getMachineActionManager(self, *args: Any) -> MachineActionManager.MachineActionManager:
|
||||
"""Get the machine action manager
|
||||
|
||||
We ignore any *args given to this, as we also register the machine manager as qml singleton.
|
||||
It wants to give this function an engine and script engine, but we don't care about that.
|
||||
"""
|
||||
|
||||
return cast(MachineActionManager.MachineActionManager, self._machine_action_manager)
|
||||
|
||||
@pyqtSlot(result = QObject)
|
||||
|
@ -1002,8 +1056,16 @@ class CuraApplication(QtApplication):
|
|||
self._simple_mode_settings_manager = SimpleModeSettingsManager()
|
||||
return self._simple_mode_settings_manager
|
||||
|
||||
## Handle Qt events
|
||||
@pyqtSlot(result = QObject)
|
||||
def getFileProviderModel(self) -> FileProviderModel:
|
||||
return self._file_provider_model
|
||||
|
||||
def _onFileProviderEnabledChanged(self):
|
||||
self._file_provider_model.update()
|
||||
|
||||
def event(self, event):
|
||||
"""Handle Qt events"""
|
||||
|
||||
if event.type() == QEvent.FileOpen:
|
||||
if self._plugins_loaded:
|
||||
self._openFile(event.file())
|
||||
|
@ -1015,8 +1077,9 @@ class CuraApplication(QtApplication):
|
|||
def getAutoSave(self) -> Optional[AutoSave]:
|
||||
return self._auto_save
|
||||
|
||||
## Get print information (duration / material used)
|
||||
def getPrintInformation(self):
|
||||
"""Get print information (duration / material used)"""
|
||||
|
||||
return self._print_information
|
||||
|
||||
def getQualityProfilesDropDownMenuModel(self, *args, **kwargs):
|
||||
|
@ -1032,10 +1095,12 @@ class CuraApplication(QtApplication):
|
|||
def getCuraAPI(self, *args, **kwargs) -> "CuraAPI":
|
||||
return self._cura_API
|
||||
|
||||
## Registers objects for the QML engine to use.
|
||||
#
|
||||
# \param engine The QML engine.
|
||||
def registerObjects(self, engine):
|
||||
"""Registers objects for the QML engine to use.
|
||||
|
||||
:param engine: The QML engine.
|
||||
"""
|
||||
|
||||
super().registerObjects(engine)
|
||||
|
||||
# global contexts
|
||||
|
@ -1084,6 +1149,7 @@ class CuraApplication(QtApplication):
|
|||
|
||||
self.processEvents()
|
||||
qmlRegisterType(DiscoveredPrintersModel, "Cura", 1, 0, "DiscoveredPrintersModel")
|
||||
qmlRegisterType(DiscoveredCloudPrintersModel, "Cura", 1, 7, "DiscoveredCloudPrintersModel")
|
||||
qmlRegisterSingletonType(QualityProfilesDropDownMenuModel, "Cura", 1, 0,
|
||||
"QualityProfilesDropDownMenuModel", self.getQualityProfilesDropDownMenuModel)
|
||||
qmlRegisterSingletonType(CustomQualityProfilesDropDownMenuModel, "Cura", 1, 0,
|
||||
|
@ -1106,6 +1172,7 @@ class CuraApplication(QtApplication):
|
|||
|
||||
from cura.API import CuraAPI
|
||||
qmlRegisterSingletonType(CuraAPI, "Cura", 1, 1, "API", self.getCuraAPI)
|
||||
qmlRegisterUncreatableType(Account, "Cura", 1, 0, "AccountSyncState", "Could not create AccountSyncState")
|
||||
|
||||
# As of Qt5.7, it is necessary to get rid of any ".." in the path for the singleton to work.
|
||||
actions_url = QUrl.fromLocalFile(os.path.abspath(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles, "Actions.qml")))
|
||||
|
@ -1169,8 +1236,9 @@ class CuraApplication(QtApplication):
|
|||
if node is not None and (node.getMeshData() is not None or node.callDecoration("getLayerData")):
|
||||
self._update_platform_activity_timer.start()
|
||||
|
||||
## Update scene bounding box for current build plate
|
||||
def updatePlatformActivity(self, node = None):
|
||||
"""Update scene bounding box for current build plate"""
|
||||
|
||||
count = 0
|
||||
scene_bounding_box = None
|
||||
is_block_slicing_node = False
|
||||
|
@ -1200,10 +1268,11 @@ class CuraApplication(QtApplication):
|
|||
if other_bb is not None:
|
||||
scene_bounding_box = scene_bounding_box + node.getBoundingBox()
|
||||
|
||||
|
||||
if print_information:
|
||||
print_information.setPreSliced(is_block_slicing_node)
|
||||
|
||||
self.getWorkspaceFileHandler().setEnabled(not is_block_slicing_node)
|
||||
|
||||
if not scene_bounding_box:
|
||||
scene_bounding_box = AxisAlignedBox.Null
|
||||
|
||||
|
@ -1214,9 +1283,10 @@ class CuraApplication(QtApplication):
|
|||
self._platform_activity = True if count > 0 else False
|
||||
self.activityChanged.emit()
|
||||
|
||||
## Select all nodes containing mesh data in the scene.
|
||||
@pyqtSlot()
|
||||
def selectAll(self):
|
||||
"""Select all nodes containing mesh data in the scene."""
|
||||
|
||||
if not self.getController().getToolsEnabled():
|
||||
return
|
||||
|
||||
|
@ -1235,9 +1305,10 @@ class CuraApplication(QtApplication):
|
|||
|
||||
Selection.add(node)
|
||||
|
||||
## Reset all translation on nodes with mesh data.
|
||||
@pyqtSlot()
|
||||
def resetAllTranslation(self):
|
||||
"""Reset all translation on nodes with mesh data."""
|
||||
|
||||
Logger.log("i", "Resetting all scene translations")
|
||||
nodes = []
|
||||
for node in DepthFirstIterator(self.getController().getScene().getRoot()):
|
||||
|
@ -1263,9 +1334,10 @@ class CuraApplication(QtApplication):
|
|||
op.addOperation(SetTransformOperation(node, Vector(0, center_y, 0)))
|
||||
op.push()
|
||||
|
||||
## Reset all transformations on nodes with mesh data.
|
||||
@pyqtSlot()
|
||||
def resetAll(self):
|
||||
"""Reset all transformations on nodes with mesh data."""
|
||||
|
||||
Logger.log("i", "Resetting all scene transformations")
|
||||
nodes = []
|
||||
for node in DepthFirstIterator(self.getController().getScene().getRoot()):
|
||||
|
@ -1291,9 +1363,10 @@ class CuraApplication(QtApplication):
|
|||
op.addOperation(SetTransformOperation(node, Vector(0, center_y, 0), Quaternion(), Vector(1, 1, 1)))
|
||||
op.push()
|
||||
|
||||
## Arrange all objects.
|
||||
@pyqtSlot()
|
||||
def arrangeObjectsToAllBuildPlates(self) -> None:
|
||||
"""Arrange all objects."""
|
||||
|
||||
nodes_to_arrange = []
|
||||
for node in DepthFirstIterator(self.getController().getScene().getRoot()):
|
||||
if not isinstance(node, SceneNode):
|
||||
|
@ -1322,6 +1395,7 @@ class CuraApplication(QtApplication):
|
|||
def arrangeAll(self) -> None:
|
||||
nodes_to_arrange = []
|
||||
active_build_plate = self.getMultiBuildPlateModel().activeBuildPlate
|
||||
locked_nodes = []
|
||||
for node in DepthFirstIterator(self.getController().getScene().getRoot()):
|
||||
if not isinstance(node, SceneNode):
|
||||
continue
|
||||
|
@ -1343,20 +1417,28 @@ class CuraApplication(QtApplication):
|
|||
# Skip nodes that are too big
|
||||
bounding_box = node.getBoundingBox()
|
||||
if bounding_box is None or bounding_box.width < self._volume.getBoundingBox().width or bounding_box.depth < self._volume.getBoundingBox().depth:
|
||||
nodes_to_arrange.append(node)
|
||||
self.arrange(nodes_to_arrange, fixed_nodes = [])
|
||||
# Arrange only the unlocked nodes and keep the locked ones in place
|
||||
if UM.Util.parseBool(node.getSetting(SceneNodeSettings.LockPosition)):
|
||||
locked_nodes.append(node)
|
||||
else:
|
||||
nodes_to_arrange.append(node)
|
||||
self.arrange(nodes_to_arrange, locked_nodes)
|
||||
|
||||
## Arrange a set of nodes given a set of fixed nodes
|
||||
# \param nodes nodes that we have to place
|
||||
# \param fixed_nodes nodes that are placed in the arranger before finding spots for nodes
|
||||
def arrange(self, nodes: List[SceneNode], fixed_nodes: List[SceneNode]) -> None:
|
||||
"""Arrange a set of nodes given a set of fixed nodes
|
||||
|
||||
:param nodes: nodes that we have to place
|
||||
:param fixed_nodes: nodes that are placed in the arranger before finding spots for nodes
|
||||
"""
|
||||
|
||||
min_offset = self.getBuildVolume().getEdgeDisallowedSize() + 2 # Allow for some rounding errors
|
||||
job = ArrangeObjectsJob(nodes, fixed_nodes, min_offset = max(min_offset, 8))
|
||||
job.start()
|
||||
|
||||
## Reload all mesh data on the screen from file.
|
||||
@pyqtSlot()
|
||||
def reloadAll(self) -> None:
|
||||
"""Reload all mesh data on the screen from file."""
|
||||
|
||||
Logger.log("i", "Reloading all loaded mesh data.")
|
||||
nodes = []
|
||||
has_merged_nodes = False
|
||||
|
@ -1382,22 +1464,30 @@ class CuraApplication(QtApplication):
|
|||
if not nodes:
|
||||
return
|
||||
|
||||
objects_in_filename = {} # type: Dict[str, List[CuraSceneNode]]
|
||||
for node in nodes:
|
||||
mesh_data = node.getMeshData()
|
||||
|
||||
if mesh_data:
|
||||
file_name = mesh_data.getFileName()
|
||||
if file_name:
|
||||
job = ReadMeshJob(file_name)
|
||||
job._node = node # type: ignore
|
||||
job.finished.connect(self._reloadMeshFinished)
|
||||
if has_merged_nodes:
|
||||
job.finished.connect(self.updateOriginOfMergedMeshes)
|
||||
|
||||
job.start()
|
||||
if file_name not in objects_in_filename:
|
||||
objects_in_filename[file_name] = []
|
||||
if file_name in objects_in_filename:
|
||||
objects_in_filename[file_name].append(node)
|
||||
else:
|
||||
Logger.log("w", "Unable to reload data because we don't have a filename.")
|
||||
|
||||
for file_name, nodes in objects_in_filename.items():
|
||||
for node in nodes:
|
||||
file_path = os.path.normpath(os.path.dirname(file_name))
|
||||
job = ReadMeshJob(file_name, add_to_recent_files = file_path != tempfile.gettempdir()) # Don't add temp files to the recent files list
|
||||
job._node = node # type: ignore
|
||||
job.finished.connect(self._reloadMeshFinished)
|
||||
if has_merged_nodes:
|
||||
job.finished.connect(self.updateOriginOfMergedMeshes)
|
||||
|
||||
job.start()
|
||||
|
||||
@pyqtSlot("QStringList")
|
||||
def setExpandedCategories(self, categories: List[str]) -> None:
|
||||
categories = list(set(categories))
|
||||
|
@ -1446,21 +1536,19 @@ class CuraApplication(QtApplication):
|
|||
|
||||
# Move each node to the same position.
|
||||
for mesh, node in zip(meshes, group_node.getChildren()):
|
||||
transformation = node.getLocalTransformation()
|
||||
transformation.setTranslation(zero_translation)
|
||||
transformed_mesh = mesh.getTransformed(transformation)
|
||||
|
||||
node.setTransformation(Matrix())
|
||||
# Align the object around its zero position
|
||||
# and also apply the offset to center it inside the group.
|
||||
node.setPosition(-transformed_mesh.getZeroPosition() - offset)
|
||||
node.setPosition(-mesh.getZeroPosition() - offset)
|
||||
|
||||
# Use the previously found center of the group bounding box as the new location of the group
|
||||
group_node.setPosition(group_node.getBoundingBox().center)
|
||||
group_node.setName("MergedMesh") # add a specific name to distinguish this node
|
||||
|
||||
|
||||
## Updates origin position of all merged meshes
|
||||
def updateOriginOfMergedMeshes(self, _):
|
||||
"""Updates origin position of all merged meshes"""
|
||||
|
||||
group_nodes = []
|
||||
for node in DepthFirstIterator(self.getController().getScene().getRoot()):
|
||||
if isinstance(node, CuraSceneNode) and node.getName() == "MergedMesh":
|
||||
|
@ -1572,13 +1660,31 @@ class CuraApplication(QtApplication):
|
|||
fileLoaded = pyqtSignal(str)
|
||||
fileCompleted = pyqtSignal(str)
|
||||
|
||||
def _reloadMeshFinished(self, job):
|
||||
# TODO; This needs to be fixed properly. We now make the assumption that we only load a single mesh!
|
||||
job_result = job.getResult()
|
||||
def _reloadMeshFinished(self, job) -> None:
|
||||
"""
|
||||
Function called whenever a ReadMeshJob finishes in the background. It reloads a specific node object in the
|
||||
scene from its source file. The function gets all the nodes that exist in the file through the job result, and
|
||||
then finds the scene node that it wants to refresh by its object id. Each job refreshes only one node.
|
||||
|
||||
:param job: The :py:class:`Uranium.UM.ReadMeshJob.ReadMeshJob` running in the background that reads all the
|
||||
meshes in a file
|
||||
"""
|
||||
|
||||
job_result = job.getResult() # nodes that exist inside the file read by this job
|
||||
if len(job_result) == 0:
|
||||
Logger.log("e", "Reloading the mesh failed.")
|
||||
return
|
||||
mesh_data = job_result[0].getMeshData()
|
||||
object_found = False
|
||||
mesh_data = None
|
||||
# Find the node to be refreshed based on its id
|
||||
for job_result_node in job_result:
|
||||
if job_result_node.getId() == job._node.getId():
|
||||
mesh_data = job_result_node.getMeshData()
|
||||
object_found = True
|
||||
break
|
||||
if not object_found:
|
||||
Logger.warning("The object with id {} no longer exists! Keeping the old version in the scene.".format(job_result_node.getId()))
|
||||
return
|
||||
if not mesh_data:
|
||||
Logger.log("w", "Could not find a mesh in reloaded node.")
|
||||
return
|
||||
|
@ -1609,12 +1715,15 @@ class CuraApplication(QtApplication):
|
|||
def additionalComponents(self):
|
||||
return self._additional_components
|
||||
|
||||
## Add a component to a list of components to be reparented to another area in the GUI.
|
||||
# The actual reparenting is done by the area itself.
|
||||
# \param area_id \type{str} Identifying name of the area to which the component should be reparented
|
||||
# \param component \type{QQuickComponent} The component that should be reparented
|
||||
@pyqtSlot(str, "QVariant")
|
||||
def addAdditionalComponent(self, area_id, component):
|
||||
def addAdditionalComponent(self, area_id: str, component):
|
||||
"""Add a component to a list of components to be reparented to another area in the GUI.
|
||||
|
||||
The actual reparenting is done by the area itself.
|
||||
:param area_id: dentifying name of the area to which the component should be reparented
|
||||
:param (QQuickComponent) component: The component that should be reparented
|
||||
"""
|
||||
|
||||
if area_id not in self._additional_components:
|
||||
self._additional_components[area_id] = []
|
||||
self._additional_components[area_id].append(component)
|
||||
|
@ -1625,14 +1734,19 @@ class CuraApplication(QtApplication):
|
|||
def log(self, msg):
|
||||
Logger.log("d", msg)
|
||||
|
||||
openProjectFile = pyqtSignal(QUrl, arguments = ["project_file"]) # Emitted when a project file is about to open.
|
||||
openProjectFile = pyqtSignal(QUrl, bool, arguments = ["project_file", "add_to_recent_files"]) # Emitted when a project file is about to open.
|
||||
|
||||
@pyqtSlot(QUrl, str, bool)
|
||||
@pyqtSlot(QUrl, str)
|
||||
@pyqtSlot(QUrl)
|
||||
## Open a local file
|
||||
# \param project_mode How to handle project files. Either None(default): Follow user preference, "open_as_model" or
|
||||
# "open_as_project". This parameter is only considered if the file is a project file.
|
||||
def readLocalFile(self, file: QUrl, project_mode: Optional[str] = None):
|
||||
def readLocalFile(self, file: QUrl, project_mode: Optional[str] = None, add_to_recent_files: bool = True):
|
||||
"""Open a local file
|
||||
|
||||
:param project_mode: How to handle project files. Either None(default): Follow user preference, "open_as_model"
|
||||
or "open_as_project". This parameter is only considered if the file is a project file.
|
||||
:param add_to_recent_files: Whether or not to add the file as an option to the Recent Files list.
|
||||
"""
|
||||
Logger.log("i", "Attempting to read file %s", file.toString())
|
||||
if not file.isValid():
|
||||
return
|
||||
|
||||
|
@ -1651,12 +1765,12 @@ class CuraApplication(QtApplication):
|
|||
if is_project_file and project_mode == "open_as_project":
|
||||
# open as project immediately without presenting a dialog
|
||||
workspace_handler = self.getWorkspaceFileHandler()
|
||||
workspace_handler.readLocalFile(file)
|
||||
workspace_handler.readLocalFile(file, add_to_recent_files_hint = add_to_recent_files)
|
||||
return
|
||||
|
||||
if is_project_file and project_mode == "always_ask":
|
||||
# present a dialog asking to open as project or import models
|
||||
self.callLater(self.openProjectFile.emit, file)
|
||||
self.callLater(self.openProjectFile.emit, file, add_to_recent_files)
|
||||
return
|
||||
|
||||
# Either the file is a model file or we want to load only models from project. Continue to load models.
|
||||
|
@ -1692,7 +1806,7 @@ class CuraApplication(QtApplication):
|
|||
if extension in self._non_sliceable_extensions:
|
||||
self.deleteAll(only_selectable = False)
|
||||
|
||||
job = ReadMeshJob(f)
|
||||
job = ReadMeshJob(f, add_to_recent_files = add_to_recent_files)
|
||||
job.finished.connect(self._readMeshFinished)
|
||||
job.start()
|
||||
|
||||
|
@ -1701,8 +1815,14 @@ class CuraApplication(QtApplication):
|
|||
if not global_container_stack:
|
||||
Logger.log("w", "Can't load meshes before a printer is added.")
|
||||
return
|
||||
if not self._volume:
|
||||
Logger.log("w", "Can't load meshes before the build volume is initialized")
|
||||
return
|
||||
|
||||
nodes = job.getResult()
|
||||
if nodes is None:
|
||||
Logger.error("Read mesh job returned None. Mesh loading must have failed.")
|
||||
return
|
||||
file_name = job.getFileName()
|
||||
file_name_lower = file_name.lower()
|
||||
file_extension = file_name_lower.split(".")[-1]
|
||||
|
@ -1716,17 +1836,21 @@ class CuraApplication(QtApplication):
|
|||
for node_ in DepthFirstIterator(root):
|
||||
if node_.callDecoration("isSliceable") and node_.callDecoration("getBuildPlateNumber") == target_build_plate:
|
||||
fixed_nodes.append(node_)
|
||||
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 = fixed_nodes)
|
||||
min_offset = 8
|
||||
|
||||
default_extruder_position = self.getMachineManager().defaultExtruderPosition
|
||||
default_extruder_id = self._global_container_stack.extruderList[int(default_extruder_position)].getId()
|
||||
|
||||
select_models_on_load = self.getPreferences().getValue("cura/select_models_on_load")
|
||||
|
||||
for original_node in nodes:
|
||||
nodes_to_arrange = [] # type: List[CuraSceneNode]
|
||||
|
||||
fixed_nodes = []
|
||||
for node_ in DepthFirstIterator(self.getController().getScene().getRoot()):
|
||||
# Only count sliceable objects
|
||||
if node_.callDecoration("isSliceable"):
|
||||
fixed_nodes.append(node_)
|
||||
|
||||
for original_node in nodes:
|
||||
# Create a CuraSceneNode just if the original node is not that type
|
||||
if isinstance(original_node, CuraSceneNode):
|
||||
node = original_node
|
||||
|
@ -1734,8 +1858,8 @@ class CuraApplication(QtApplication):
|
|||
node = CuraSceneNode()
|
||||
node.setMeshData(original_node.getMeshData())
|
||||
|
||||
#Setting meshdata does not apply scaling.
|
||||
if(original_node.getScale() != Vector(1.0, 1.0, 1.0)):
|
||||
# Setting meshdata does not apply scaling.
|
||||
if original_node.getScale() != Vector(1.0, 1.0, 1.0):
|
||||
node.scale(original_node.getScale())
|
||||
|
||||
node.setSelectable(True)
|
||||
|
@ -1766,19 +1890,15 @@ class CuraApplication(QtApplication):
|
|||
|
||||
if file_extension != "3mf":
|
||||
if node.callDecoration("isSliceable"):
|
||||
# Only check position if it's not already blatantly obvious that it won't fit.
|
||||
if node.getBoundingBox() is None or self._volume.getBoundingBox() is None or node.getBoundingBox().width < self._volume.getBoundingBox().width or node.getBoundingBox().depth < self._volume.getBoundingBox().depth:
|
||||
# Find node location
|
||||
offset_shape_arr, hull_shape_arr = ShapeArray.fromNode(node, min_offset = min_offset)
|
||||
# Ensure that the bottom of the bounding box is on the build plate
|
||||
if node.getBoundingBox():
|
||||
center_y = node.getWorldPosition().y - node.getBoundingBox().bottom
|
||||
else:
|
||||
center_y = 0
|
||||
|
||||
# If a model is to small then it will not contain any points
|
||||
if offset_shape_arr is None and hull_shape_arr is None:
|
||||
Message(self._i18n_catalog.i18nc("@info:status", "The selected model was too small to load."),
|
||||
title=self._i18n_catalog.i18nc("@info:title", "Warning")).show()
|
||||
return
|
||||
node.translate(Vector(0, center_y, 0))
|
||||
|
||||
# Step is for skipping tests to make it a lot faster. it also makes the outcome somewhat rougher
|
||||
arranger.findNodePlacement(node, offset_shape_arr, hull_shape_arr, step = 10)
|
||||
nodes_to_arrange.append(node)
|
||||
|
||||
# This node is deep copied from some other node which already has a BuildPlateDecorator, but the deepcopy
|
||||
# of BuildPlateDecorator produces one that's associated with build plate -1. So, here we need to check if
|
||||
|
@ -1797,6 +1917,14 @@ class CuraApplication(QtApplication):
|
|||
|
||||
if select_models_on_load:
|
||||
Selection.add(node)
|
||||
try:
|
||||
arrange(nodes_to_arrange, self.getBuildVolume(), fixed_nodes)
|
||||
except:
|
||||
Logger.logException("e", "Failed to arrange the models")
|
||||
|
||||
# Ensure that we don't have any weird floaty objects (CURA-7855)
|
||||
for node in nodes_to_arrange:
|
||||
node.translate(Vector(0, -node.getBoundingBox().bottom, 0), SceneNode.TransformSpace.World)
|
||||
|
||||
self.fileCompleted.emit(file_name)
|
||||
|
||||
|
@ -1805,9 +1933,8 @@ class CuraApplication(QtApplication):
|
|||
|
||||
@pyqtSlot(str, result=bool)
|
||||
def checkIsValidProjectFile(self, file_url):
|
||||
"""
|
||||
Checks if the given file URL is a valid project file.
|
||||
"""
|
||||
"""Checks if the given file URL is a valid project file. """
|
||||
|
||||
file_path = QUrl(file_url).toLocalFile()
|
||||
workspace_reader = self.getWorkspaceFileHandler().getReaderForFile(file_path)
|
||||
if workspace_reader is None:
|
||||
|
@ -1815,7 +1942,7 @@ class CuraApplication(QtApplication):
|
|||
try:
|
||||
result = workspace_reader.preRead(file_path, show_dialog=False)
|
||||
return result == WorkspaceReader.PreReadResult.accepted
|
||||
except Exception:
|
||||
except:
|
||||
Logger.logException("e", "Could not check file %s", file_url)
|
||||
return False
|
||||
|
||||
|
@ -1825,7 +1952,6 @@ class CuraApplication(QtApplication):
|
|||
return
|
||||
selection_pass = cast(SelectionPass, self.getRenderer().getRenderPass("selection"))
|
||||
if not selection_pass: # If you right-click before the rendering has been initialised there might not be a selection pass yet.
|
||||
print("--------------ding! Got the crash.")
|
||||
return
|
||||
node = self.getController().getScene().findObject(selection_pass.getIdAtPosition(x, y))
|
||||
if not node:
|
||||
|
|
|
@ -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,21 +62,21 @@ 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._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)
|
||||
# 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]
|
||||
|
@ -83,19 +86,22 @@ class LayerPolygon:
|
|||
self._vertex_begin = 0
|
||||
self._vertex_end = 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 +111,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 +142,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 +195,7 @@ class LayerPolygon:
|
|||
@property
|
||||
def lineFeedrates(self):
|
||||
return self._line_feedrates
|
||||
|
||||
|
||||
@property
|
||||
def jumpMask(self):
|
||||
return self._jump_mask
|
||||
|
@ -202,8 +208,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 +239,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,18 @@ class ExtrudersModel(ListModel):
|
|||
MaterialBrandRole = Qt.UserRole + 9
|
||||
ColorNameRole = Qt.UserRole + 10
|
||||
|
||||
## Is the extruder enabled?
|
||||
EnabledRole = Qt.UserRole + 11
|
||||
"""Is the extruder enabled?"""
|
||||
|
||||
## 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")
|
||||
|
@ -101,14 +101,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 +147,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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -19,28 +19,31 @@ 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)
|
||||
"""Proxy class to the materials page in the preferences.
|
||||
|
||||
This class handles the actions in that page, such as creating new materials, renaming them, etc.
|
||||
"""
|
||||
|
||||
favoritesChanged = pyqtSignal(str)
|
||||
"""Triggered when a favorite is added or removed.
|
||||
|
||||
:param The base file of the material is provided as parameter when this emits
|
||||
"""
|
||||
|
||||
## 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 +51,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 +66,20 @@ 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.
|
||||
"""
|
||||
|
||||
container_registry = CuraContainerRegistry.getInstance()
|
||||
materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file)
|
||||
|
||||
|
@ -89,17 +97,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 +181,32 @@ 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.
|
||||
"""
|
||||
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 +231,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 +246,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:
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
@ -101,7 +109,8 @@ 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()}
|
||||
|
@ -152,7 +161,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:
|
||||
|
|
|
@ -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,15 @@ 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"))
|
||||
no_full_solution_message.show()
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2020 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
|
||||
from typing import Optional, Any, Dict, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
|
@ -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,12 +102,17 @@ 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:
|
||||
|
@ -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")
|
||||
|
@ -108,8 +117,9 @@ class AuthorizationService:
|
|||
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 +134,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 +148,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 +168,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 +177,43 @@ 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")).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 +221,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
|
||||
|
@ -200,16 +241,17 @@ class AuthorizationService:
|
|||
|
||||
self._unable_to_get_data_message = Message(i18n_catalog.i18nc("@info", "Unable to reach the Ultimaker account server."), title = i18n_catalog.i18nc("@info:title", "Warning"))
|
||||
self._unable_to_get_data_message.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()
|
||||
|
|
|
@ -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,10 +44,13 @@ 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.
|
||||
|
@ -63,18 +71,37 @@ 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
|
||||
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
|
||||
"""
|
||||
if self._web_server:
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
self._web_server.serve_forever()
|
||||
except OSError as e:
|
||||
Logger.warning(str(e))
|
||||
else:
|
||||
# Leave the default behavior in non-windows platforms
|
||||
self._web_server.serve_forever()
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# 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 Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
|
||||
class BaseModel:
|
||||
|
@ -8,8 +8,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,16 +21,20 @@ 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]
|
||||
|
@ -40,22 +45,25 @@ class AuthenticationResponse(BaseModel):
|
|||
received_at = None # type: Optional[str]
|
||||
|
||||
|
||||
## 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:
|
||||
|
|
|
@ -95,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
|
||||
|
@ -181,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
|
||||
|
@ -36,16 +38,19 @@ class FirmwareUpdater(QObject):
|
|||
if self._firmware_file == "":
|
||||
self._setFirmwareUpdateState(FirmwareUpdateState.firmware_not_found_error)
|
||||
return
|
||||
|
||||
self._setFirmwareUpdateState(FirmwareUpdateState.updating)
|
||||
|
||||
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.")
|
||||
|
|
|
@ -2,8 +2,9 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
|
|||
from cura.Scene.CuraSceneNode import CuraSceneNode
|
||||
|
||||
|
||||
## Make a SceneNode build plate aware CuraSceneNode objects all have this decorator.
|
||||
class BuildPlateDecorator(SceneNodeDecorator):
|
||||
"""Make a SceneNode build plate aware CuraSceneNode objects all have this decorator."""
|
||||
|
||||
def __init__(self, build_plate_number: int = -1) -> None:
|
||||
super().__init__()
|
||||
self._build_plate_number = build_plate_number
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
# Copyright (c) 2016 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 UM.Application import Application
|
||||
from UM.Math.Polygon import Polygon
|
||||
|
||||
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
|
||||
from UM.Settings.ContainerRegistry import ContainerRegistry
|
||||
|
||||
|
@ -23,9 +22,12 @@ if TYPE_CHECKING:
|
|||
from UM.Math.Matrix import Matrix
|
||||
|
||||
|
||||
## The convex hull decorator is a scene node decorator that adds the convex hull functionality to a scene node.
|
||||
# If a scene node has a convex hull decorator, it will have a shadow in which other objects can not be printed.
|
||||
class ConvexHullDecorator(SceneNodeDecorator):
|
||||
"""The convex hull decorator is a scene node decorator that adds the convex hull functionality to a scene node.
|
||||
|
||||
If a scene node has a convex hull decorator, it will have a shadow in which other objects can not be printed.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
|
@ -47,8 +49,12 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
self._build_volume.raftThicknessChanged.connect(self._onChanged)
|
||||
|
||||
CuraApplication.getInstance().globalContainerStackChanged.connect(self._onGlobalStackChanged)
|
||||
CuraApplication.getInstance().getController().toolOperationStarted.connect(self._onChanged)
|
||||
CuraApplication.getInstance().getController().toolOperationStopped.connect(self._onChanged)
|
||||
controller = CuraApplication.getInstance().getController()
|
||||
controller.toolOperationStarted.connect(self._onChanged)
|
||||
controller.toolOperationStopped.connect(self._onChanged)
|
||||
#CuraApplication.getInstance().sceneBoundingBoxChanged.connect(self._onChanged)
|
||||
|
||||
self._root = Application.getInstance().getController().getScene().getRoot()
|
||||
|
||||
self._onGlobalStackChanged()
|
||||
|
||||
|
@ -74,13 +80,16 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
|
||||
self._onChanged()
|
||||
|
||||
## Force that a new (empty) object is created upon copy.
|
||||
def __deepcopy__(self, memo):
|
||||
"""Force that a new (empty) object is created upon copy."""
|
||||
|
||||
return ConvexHullDecorator()
|
||||
|
||||
## The polygon representing the 2D adhesion area.
|
||||
# If no adhesion is used, the regular convex hull is returned
|
||||
def getAdhesionArea(self) -> Optional[Polygon]:
|
||||
"""The polygon representing the 2D adhesion area.
|
||||
|
||||
If no adhesion is used, the regular convex hull is returned
|
||||
"""
|
||||
if self._node is None:
|
||||
return None
|
||||
|
||||
|
@ -90,9 +99,11 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
|
||||
return self._add2DAdhesionMargin(hull)
|
||||
|
||||
## Get the unmodified 2D projected convex hull of the node (if any)
|
||||
# In case of one-at-a-time, this includes adhesion and head+fans clearance
|
||||
def getConvexHull(self) -> Optional[Polygon]:
|
||||
"""Get the unmodified 2D projected convex hull of the node (if any)
|
||||
|
||||
In case of one-at-a-time, this includes adhesion and head+fans clearance
|
||||
"""
|
||||
if self._node is None:
|
||||
return None
|
||||
if self._node.callDecoration("isNonPrintingMesh"):
|
||||
|
@ -108,9 +119,11 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
|
||||
return self._compute2DConvexHull()
|
||||
|
||||
## For one at the time this is the convex hull of the node with the full head size
|
||||
# In case of printing all at once this is None.
|
||||
def getConvexHullHeadFull(self) -> Optional[Polygon]:
|
||||
"""For one at the time this is the convex hull of the node with the full head size
|
||||
|
||||
In case of printing all at once this is None.
|
||||
"""
|
||||
if self._node is None:
|
||||
return None
|
||||
|
||||
|
@ -126,10 +139,12 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
return False
|
||||
return bool(parent.callDecoration("isGroup"))
|
||||
|
||||
## Get convex hull of the object + head size
|
||||
# In case of printing all at once this is None.
|
||||
# For one at the time this is area with intersection of mirrored head
|
||||
def getConvexHullHead(self) -> Optional[Polygon]:
|
||||
"""Get convex hull of the object + head size
|
||||
|
||||
In case of printing all at once this is None.
|
||||
For one at the time this is area with intersection of mirrored head
|
||||
"""
|
||||
if self._node is None:
|
||||
return None
|
||||
if self._node.callDecoration("isNonPrintingMesh"):
|
||||
|
@ -142,10 +157,12 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
return head_with_fans_with_adhesion_margin
|
||||
return None
|
||||
|
||||
## Get convex hull of the node
|
||||
# In case of printing all at once this None??
|
||||
# For one at the time this is the area without the head.
|
||||
def getConvexHullBoundary(self) -> Optional[Polygon]:
|
||||
"""Get convex hull of the node
|
||||
|
||||
In case of printing all at once this None??
|
||||
For one at the time this is the area without the head.
|
||||
"""
|
||||
if self._node is None:
|
||||
return None
|
||||
|
||||
|
@ -157,10 +174,12 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
return self._compute2DConvexHull()
|
||||
return None
|
||||
|
||||
## Get the buildplate polygon where will be printed
|
||||
# In case of printing all at once this is the same as convex hull (no individual adhesion)
|
||||
# For one at the time this includes the adhesion area
|
||||
def getPrintingArea(self) -> Optional[Polygon]:
|
||||
"""Get the buildplate polygon where will be printed
|
||||
|
||||
In case of printing all at once this is the same as convex hull (no individual adhesion)
|
||||
For one at the time this includes the adhesion area
|
||||
"""
|
||||
if self._isSingularOneAtATimeNode():
|
||||
# In one-at-a-time mode, every printed object gets it's own adhesion
|
||||
printing_area = self.getAdhesionArea()
|
||||
|
@ -168,8 +187,8 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
printing_area = self.getConvexHull()
|
||||
return printing_area
|
||||
|
||||
## The same as recomputeConvexHull, but using a timer if it was set.
|
||||
def recomputeConvexHullDelayed(self) -> None:
|
||||
"""The same as recomputeConvexHull, but using a timer if it was set."""
|
||||
if self._recompute_convex_hull_timer is not None:
|
||||
self._recompute_convex_hull_timer.start()
|
||||
else:
|
||||
|
@ -181,23 +200,16 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
CuraApplication.getInstance().callLater(self.recomputeConvexHullDelayed)
|
||||
|
||||
def recomputeConvexHull(self) -> None:
|
||||
controller = Application.getInstance().getController()
|
||||
root = controller.getScene().getRoot()
|
||||
if self._node is None or controller.isToolOperationActive() or not self.__isDescendant(root, self._node):
|
||||
# If the tool operation is still active, we need to compute the convex hull later after the controller is
|
||||
# no longer active.
|
||||
if controller.isToolOperationActive():
|
||||
self.recomputeConvexHullDelayed()
|
||||
return
|
||||
|
||||
if self._node is None or not self.__isDescendant(self._root, self._node):
|
||||
if self._convex_hull_node:
|
||||
# Convex hull node still exists, but the node is removed or no longer in the scene.
|
||||
self._convex_hull_node.setParent(None)
|
||||
self._convex_hull_node = None
|
||||
return
|
||||
|
||||
if self._convex_hull_node:
|
||||
self._convex_hull_node.setParent(None)
|
||||
hull_node = ConvexHullNode.ConvexHullNode(self._node, self.getPrintingArea(), self._raft_thickness, root)
|
||||
hull_node = ConvexHullNode.ConvexHullNode(self._node, self.getPrintingArea(), self._raft_thickness, self._root)
|
||||
self._convex_hull_node = hull_node
|
||||
|
||||
def _onSettingValueChanged(self, key: str, property_name: str) -> None:
|
||||
|
@ -224,7 +236,7 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
if self._node is None:
|
||||
return None
|
||||
if self._node.callDecoration("isGroup"):
|
||||
points = numpy.zeros((0, 2), dtype=numpy.int32)
|
||||
points = numpy.zeros((0, 2), dtype = numpy.int32)
|
||||
for child in self._node.getChildren():
|
||||
child_hull = child.callDecoration("_compute2DConvexHull")
|
||||
if child_hull:
|
||||
|
@ -251,16 +263,17 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
return offset_hull
|
||||
|
||||
else:
|
||||
convex_hull = Polygon([])
|
||||
offset_hull = Polygon([])
|
||||
mesh = self._node.getMeshData()
|
||||
if mesh is None:
|
||||
return Polygon([]) # Node has no mesh data, so just return an empty Polygon.
|
||||
|
||||
world_transform = self._node.getWorldTransformation()
|
||||
world_transform = self._node.getWorldTransformation(copy = True)
|
||||
|
||||
# Check the cache
|
||||
if mesh is self._2d_convex_hull_mesh and world_transform == self._2d_convex_hull_mesh_world_transform:
|
||||
return self._2d_convex_hull_mesh_result
|
||||
return self._offsetHull(self._2d_convex_hull_mesh_result)
|
||||
|
||||
vertex_data = mesh.getConvexHullTransformedVertices(world_transform)
|
||||
# Don't use data below 0.
|
||||
|
@ -268,7 +281,7 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
# Do not throw away vertices: the convex hull may be too small and objects can collide.
|
||||
# vertex_data = vertex_data[vertex_data[:,1] >= -0.01]
|
||||
|
||||
if len(vertex_data) >= 4: # type: ignore # mypy and numpy don't play along well just yet.
|
||||
if vertex_data is not None and len(vertex_data) >= 4: # type: ignore # mypy and numpy don't play along well just yet.
|
||||
# Round the vertex data to 1/10th of a mm, then remove all duplicate vertices
|
||||
# This is done to greatly speed up further convex hull calculations as the convex hull
|
||||
# becomes much less complex when dealing with highly detailed models.
|
||||
|
@ -295,7 +308,7 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
# Store the result in the cache
|
||||
self._2d_convex_hull_mesh = mesh
|
||||
self._2d_convex_hull_mesh_world_transform = world_transform
|
||||
self._2d_convex_hull_mesh_result = offset_hull
|
||||
self._2d_convex_hull_mesh_result = convex_hull
|
||||
|
||||
return offset_hull
|
||||
|
||||
|
@ -325,9 +338,11 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
return convex_hull.getMinkowskiHull(head_and_fans)
|
||||
return None
|
||||
|
||||
## Compensate given 2D polygon with adhesion margin
|
||||
# \return 2D polygon with added margin
|
||||
def _add2DAdhesionMargin(self, poly: Polygon) -> Polygon:
|
||||
"""Compensate given 2D polygon with adhesion margin
|
||||
|
||||
:return: 2D polygon with added margin
|
||||
"""
|
||||
if not self._global_stack:
|
||||
return Polygon()
|
||||
# Compensate for raft/skirt/brim
|
||||
|
@ -358,17 +373,48 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
poly = poly.getMinkowskiHull(extra_margin_polygon)
|
||||
return poly
|
||||
|
||||
## Offset the convex hull with settings that influence the collision area.
|
||||
#
|
||||
# \param convex_hull Polygon of the original convex hull.
|
||||
# \return New Polygon instance that is offset with everything that
|
||||
# influences the collision area.
|
||||
def _offsetHull(self, convex_hull: Polygon) -> Polygon:
|
||||
"""Offset the convex hull with settings that influence the collision area.
|
||||
|
||||
:param convex_hull: Polygon of the original convex hull.
|
||||
:return: New Polygon instance that is offset with everything that
|
||||
influences the collision area.
|
||||
"""
|
||||
# Shrinkage compensation.
|
||||
if not self._global_stack: # Should never happen.
|
||||
return convex_hull
|
||||
scale_factor = self._global_stack.getProperty("material_shrinkage_percentage", "value") / 100.0
|
||||
result = convex_hull
|
||||
if scale_factor != 1.0 and not self.getNode().callDecoration("isGroup"):
|
||||
center = None
|
||||
if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time":
|
||||
# Find the root node that's placed in the scene; the root of the mesh group.
|
||||
ancestor = self.getNode()
|
||||
while ancestor.getParent() != self._root:
|
||||
ancestor = ancestor.getParent()
|
||||
center = ancestor.getBoundingBox().center
|
||||
else:
|
||||
# Find the bounding box of the entire scene, which is all one mesh group then.
|
||||
aabb = None
|
||||
for printed_node in self._root.getChildren():
|
||||
if not printed_node.callDecoration("isSliceable") and not printed_node.callDecoration("isGroup"):
|
||||
continue # Not a printed node.
|
||||
if aabb is None:
|
||||
aabb = printed_node.getBoundingBox()
|
||||
else:
|
||||
aabb = aabb + printed_node.getBoundingBox()
|
||||
if aabb:
|
||||
center = aabb.center
|
||||
if center:
|
||||
result = convex_hull.scale(scale_factor, [center.x, center.z]) # Yes, use Z instead of Y. Mixed conventions there with how the OpenGL coordinates are transmitted.
|
||||
|
||||
# Horizontal expansion.
|
||||
horizontal_expansion = max(
|
||||
self._getSettingProperty("xy_offset", "value"),
|
||||
self._getSettingProperty("xy_offset_layer_0", "value")
|
||||
)
|
||||
|
||||
# Mold.
|
||||
mold_width = 0
|
||||
if self._getSettingProperty("mold_enabled", "value"):
|
||||
mold_width = self._getSettingProperty("mold_width", "value")
|
||||
|
@ -380,14 +426,13 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
[hull_offset, hull_offset],
|
||||
[hull_offset, -hull_offset]
|
||||
], numpy.float32))
|
||||
return convex_hull.getMinkowskiHull(expansion_polygon)
|
||||
return result.getMinkowskiHull(expansion_polygon)
|
||||
else:
|
||||
return convex_hull
|
||||
return result
|
||||
|
||||
def _onChanged(self, *args) -> None:
|
||||
self._raft_thickness = self._build_volume.getRaftThickness()
|
||||
if not args or args[0] == self._node:
|
||||
self.recomputeConvexHullDelayed()
|
||||
self.recomputeConvexHullDelayed()
|
||||
|
||||
def _onGlobalStackChanged(self) -> None:
|
||||
if self._global_stack:
|
||||
|
@ -409,8 +454,9 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
|
||||
self._onChanged()
|
||||
|
||||
## Private convenience function to get a setting from the correct extruder (as defined by limit_to_extruder property).
|
||||
def _getSettingProperty(self, setting_key: str, prop: str = "value") -> Any:
|
||||
"""Private convenience function to get a setting from the correct extruder (as defined by limit_to_extruder property)."""
|
||||
|
||||
if self._global_stack is None or self._node is None:
|
||||
return None
|
||||
per_mesh_stack = self._node.callDecoration("getStack")
|
||||
|
@ -430,16 +476,18 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
# Limit_to_extruder is set. The global stack handles this then
|
||||
return self._global_stack.getProperty(setting_key, prop)
|
||||
|
||||
## Returns True if node is a descendant or the same as the root node.
|
||||
def __isDescendant(self, root: "SceneNode", node: Optional["SceneNode"]) -> bool:
|
||||
"""Returns True if node is a descendant or the same as the root node."""
|
||||
|
||||
if node is None:
|
||||
return False
|
||||
if root is node:
|
||||
return True
|
||||
return self.__isDescendant(root, node.getParent())
|
||||
|
||||
## True if print_sequence is one_at_a_time and _node is not part of a group
|
||||
def _isSingularOneAtATimeNode(self) -> bool:
|
||||
"""True if print_sequence is one_at_a_time and _node is not part of a group"""
|
||||
|
||||
if self._node is None:
|
||||
return False
|
||||
return self._global_stack is not None \
|
||||
|
@ -450,7 +498,8 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
|||
"adhesion_type", "raft_margin", "print_sequence",
|
||||
"skirt_gap", "skirt_line_count", "skirt_brim_line_width", "skirt_distance", "brim_line_count"]
|
||||
|
||||
## Settings that change the convex hull.
|
||||
#
|
||||
# If these settings change, the convex hull should be recalculated.
|
||||
_influencing_settings = {"xy_offset", "xy_offset_layer_0", "mold_enabled", "mold_width", "anti_overhang_mesh", "infill_mesh", "cutting_mesh"}
|
||||
_influencing_settings = {"xy_offset", "xy_offset_layer_0", "mold_enabled", "mold_width", "anti_overhang_mesh", "infill_mesh", "cutting_mesh", "material_shrinkage_percentage"}
|
||||
"""Settings that change the convex hull.
|
||||
|
||||
If these settings change, the convex hull should be recalculated.
|
||||
"""
|
||||
|
|
|
@ -18,11 +18,13 @@ if TYPE_CHECKING:
|
|||
class ConvexHullNode(SceneNode):
|
||||
shader = None # To prevent the shader from being re-built over and over again, only load it once.
|
||||
|
||||
## Convex hull node is a special type of scene node that is used to display an area, to indicate the
|
||||
# location an object uses on the buildplate. This area (or area's in case of one at a time printing) is
|
||||
# then displayed as a transparent shadow. If the adhesion type is set to raft, the area is extruded
|
||||
# to represent the raft as well.
|
||||
def __init__(self, node: SceneNode, hull: Optional[Polygon], thickness: float, parent: Optional[SceneNode] = None) -> None:
|
||||
"""Convex hull node is a special type of scene node that is used to display an area, to indicate the
|
||||
|
||||
location an object uses on the buildplate. This area (or area's in case of one at a time printing) is
|
||||
then displayed as a transparent shadow. If the adhesion type is set to raft, the area is extruded
|
||||
to represent the raft as well.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
|
||||
self.setCalculateBoundingBox(False)
|
||||
|
@ -55,13 +57,21 @@ class ConvexHullNode(SceneNode):
|
|||
self._hull = hull
|
||||
if self._hull:
|
||||
hull_mesh_builder = MeshBuilder()
|
||||
if self._thickness == 0:
|
||||
if hull_mesh_builder.addConvexPolygon(
|
||||
self._hull.getPoints()[::], # bottom layer is reversed
|
||||
self._mesh_height, color = self._color):
|
||||
hull_mesh_builder.resetNormals()
|
||||
|
||||
if hull_mesh_builder.addConvexPolygonExtrusion(
|
||||
self._hull.getPoints()[::-1], # bottom layer is reversed
|
||||
self._mesh_height - thickness, self._mesh_height, color = self._color):
|
||||
|
||||
hull_mesh = hull_mesh_builder.build()
|
||||
self.setMeshData(hull_mesh)
|
||||
hull_mesh = hull_mesh_builder.build()
|
||||
self.setMeshData(hull_mesh)
|
||||
else:
|
||||
if hull_mesh_builder.addConvexPolygonExtrusion(
|
||||
self._hull.getPoints()[::-1], # bottom layer is reversed
|
||||
self._mesh_height - thickness, self._mesh_height, color = self._color):
|
||||
hull_mesh_builder.resetNormals()
|
||||
hull_mesh = hull_mesh_builder.build()
|
||||
self.setMeshData(hull_mesh)
|
||||
|
||||
def getHull(self):
|
||||
return self._hull
|
||||
|
@ -77,15 +87,15 @@ class ConvexHullNode(SceneNode):
|
|||
ConvexHullNode.shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader"))
|
||||
ConvexHullNode.shader.setUniformValue("u_diffuseColor", self._color)
|
||||
ConvexHullNode.shader.setUniformValue("u_opacity", 0.6)
|
||||
|
||||
if self.getParent():
|
||||
if self.getMeshData() and isinstance(self._node, SceneNode) and self._node.callDecoration("getBuildPlateNumber") == Application.getInstance().getMultiBuildPlateModel().activeBuildPlate:
|
||||
# The object itself (+ adhesion in one-at-a-time mode)
|
||||
renderer.queueNode(self, transparent = True, shader = ConvexHullNode.shader, backface_cull = True, sort = -8)
|
||||
if self._convex_hull_head_mesh:
|
||||
# The full head. Rendered as a hint to the user: If this area overlaps another object A; this object
|
||||
# cannot be printed after A, because the head would hit A while printing the current object
|
||||
renderer.queueNode(self, shader = ConvexHullNode.shader, transparent = True, mesh = self._convex_hull_head_mesh, backface_cull = True, sort = -8)
|
||||
batch = renderer.getNamedBatch("convex_hull_node")
|
||||
if not batch:
|
||||
batch = renderer.createRenderBatch(transparent = True, shader = ConvexHullNode.shader, backface_cull = True, sort = -8)
|
||||
renderer.addRenderBatch(batch, name = "convex_hull_node")
|
||||
batch.addItem(self.getWorldTransformation(copy = False), self.getMeshData())
|
||||
if self._convex_hull_head_mesh:
|
||||
# The full head. Rendered as a hint to the user: If this area overlaps another object A; this object
|
||||
# cannot be printed after A, because the head would hit A while printing the current object
|
||||
renderer.queueNode(self, shader = ConvexHullNode.shader, transparent = True, mesh = self._convex_hull_head_mesh, backface_cull = True, sort = -8)
|
||||
|
||||
return True
|
||||
|
||||
|
@ -95,7 +105,3 @@ class ConvexHullNode(SceneNode):
|
|||
convex_hull_head_builder = MeshBuilder()
|
||||
convex_hull_head_builder.addConvexPolygon(convex_hull_head.getPoints(), self._mesh_height - self._thickness)
|
||||
self._convex_hull_head_mesh = convex_hull_head_builder.build()
|
||||
|
||||
if not node:
|
||||
return
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ from PyQt5.QtWidgets import QApplication
|
|||
from UM.Scene.Camera import Camera
|
||||
from cura.UI.ObjectsModel import ObjectsModel
|
||||
from cura.Machines.Models.MultiBuildPlateModel import MultiBuildPlateModel
|
||||
from cura.Scene.CuraSceneNode import CuraSceneNode
|
||||
|
||||
from UM.Application import Application
|
||||
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
|
||||
|
@ -43,6 +44,26 @@ class CuraSceneController(QObject):
|
|||
self._change_timer.start()
|
||||
|
||||
def updateMaxBuildPlate(self, *args):
|
||||
global_stack = Application.getInstance().getGlobalContainerStack()
|
||||
if global_stack:
|
||||
scene_has_support_meshes = self._sceneHasSupportMeshes() # TODO: see if this can be cached
|
||||
|
||||
if scene_has_support_meshes != global_stack.getProperty("support_meshes_present", "value"):
|
||||
# Adjust the setting without having the setting value in an InstanceContainer
|
||||
setting_definitions = global_stack.definition.findDefinitions(key="support_meshes_present")
|
||||
if setting_definitions:
|
||||
# Recreate the setting definition because the default_value is readonly
|
||||
definition_dict = setting_definitions[0].serialize_to_dict()
|
||||
definition_dict["enabled"] = False # The enabled property has a value that would need to be evaluated
|
||||
definition_dict["default_value"] = scene_has_support_meshes
|
||||
relations = setting_definitions[0].relations # Relations are wiped when deserializing from a dict
|
||||
setting_definitions[0].deserialize(definition_dict)
|
||||
|
||||
# Restore relations and notify them that the setting has changed
|
||||
for relation in relations:
|
||||
setting_definitions[0].relations.append(relation)
|
||||
global_stack.propertyChanged.emit(relation.target.key, "enabled")
|
||||
|
||||
max_build_plate = self._calcMaxBuildPlate()
|
||||
changed = False
|
||||
if max_build_plate != self._max_build_plate:
|
||||
|
@ -72,9 +93,19 @@ class CuraSceneController(QObject):
|
|||
max_build_plate = max(build_plate_number, max_build_plate)
|
||||
return max_build_plate
|
||||
|
||||
## Either select or deselect an item
|
||||
def _sceneHasSupportMeshes(self):
|
||||
root = Application.getInstance().getController().getScene().getRoot()
|
||||
for node in root.getAllChildren():
|
||||
if isinstance(node, CuraSceneNode):
|
||||
per_mesh_stack = node.callDecoration("getStack")
|
||||
if per_mesh_stack and per_mesh_stack.getProperty("support_mesh", "value"):
|
||||
return True
|
||||
return False
|
||||
|
||||
@pyqtSlot(int)
|
||||
def changeSelection(self, index):
|
||||
"""Either select or deselect an item"""
|
||||
|
||||
modifiers = QApplication.keyboardModifiers()
|
||||
ctrl_is_active = modifiers & Qt.ControlModifier
|
||||
shift_is_active = modifiers & Qt.ShiftModifier
|
||||
|
|
|
@ -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 copy import deepcopy
|
||||
|
@ -15,9 +15,11 @@ from cura.Settings.ExtruderStack import ExtruderStack # For typing.
|
|||
from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator # For per-object settings.
|
||||
|
||||
|
||||
## Scene nodes that are models are only seen when selecting the corresponding build plate
|
||||
# Note that many other nodes can just be UM SceneNode objects.
|
||||
class CuraSceneNode(SceneNode):
|
||||
"""Scene nodes that are models are only seen when selecting the corresponding build plate
|
||||
|
||||
Note that many other nodes can just be UM SceneNode objects.
|
||||
"""
|
||||
def __init__(self, parent: Optional["SceneNode"] = None, visible: bool = True, name: str = "", no_setting_override: bool = False) -> None:
|
||||
super().__init__(parent = parent, visible = visible, name = name)
|
||||
if not no_setting_override:
|
||||
|
@ -36,15 +38,23 @@ class CuraSceneNode(SceneNode):
|
|||
def isSelectable(self) -> bool:
|
||||
return super().isSelectable() and self.callDecoration("getBuildPlateNumber") == cura.CuraApplication.CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate
|
||||
|
||||
## Get the extruder used to print this node. If there is no active node, then the extruder in position zero is returned
|
||||
# TODO The best way to do it is by adding the setActiveExtruder decorator to every node when is loaded
|
||||
def isSupportMesh(self) -> bool:
|
||||
per_mesh_stack = self.callDecoration("getStack")
|
||||
if not per_mesh_stack:
|
||||
return False
|
||||
return per_mesh_stack.getProperty("support_mesh", "value")
|
||||
|
||||
def getPrintingExtruder(self) -> Optional[ExtruderStack]:
|
||||
"""Get the extruder used to print this node. If there is no active node, then the extruder in position zero is returned
|
||||
|
||||
TODO The best way to do it is by adding the setActiveExtruder decorator to every node when is loaded
|
||||
"""
|
||||
global_container_stack = Application.getInstance().getGlobalContainerStack()
|
||||
if global_container_stack is None:
|
||||
return None
|
||||
|
||||
per_mesh_stack = self.callDecoration("getStack")
|
||||
extruders = list(global_container_stack.extruders.values())
|
||||
extruders = global_container_stack.extruderList
|
||||
|
||||
# Use the support extruder instead of the active extruder if this is a support_mesh
|
||||
if per_mesh_stack:
|
||||
|
@ -69,8 +79,9 @@ class CuraSceneNode(SceneNode):
|
|||
# This point should never be reached
|
||||
return None
|
||||
|
||||
## Return the color of the material used to print this model
|
||||
def getDiffuseColor(self) -> List[float]:
|
||||
"""Return the color of the material used to print this model"""
|
||||
|
||||
printing_extruder = self.getPrintingExtruder()
|
||||
|
||||
material_color = "#808080" # Fallback color
|
||||
|
@ -86,8 +97,9 @@ class CuraSceneNode(SceneNode):
|
|||
1.0
|
||||
]
|
||||
|
||||
## Return if any area collides with the convex hull of this scene node
|
||||
def collidesWithAreas(self, areas: List[Polygon]) -> bool:
|
||||
"""Return if any area collides with the convex hull of this scene node"""
|
||||
|
||||
convex_hull = self.callDecoration("getPrintingArea")
|
||||
if convex_hull:
|
||||
if not convex_hull.isValid():
|
||||
|
@ -101,14 +113,15 @@ class CuraSceneNode(SceneNode):
|
|||
return True
|
||||
return False
|
||||
|
||||
## Override of SceneNode._calculateAABB to exclude non-printing-meshes from bounding box
|
||||
def _calculateAABB(self) -> None:
|
||||
"""Override of SceneNode._calculateAABB to exclude non-printing-meshes from bounding box"""
|
||||
|
||||
self._aabb = None
|
||||
if self._mesh_data:
|
||||
self._aabb = self._mesh_data.getExtents(self.getWorldTransformation())
|
||||
else: # If there is no mesh_data, use a boundingbox that encompasses the local (0,0,0)
|
||||
self._aabb = self._mesh_data.getExtents(self.getWorldTransformation(copy = False))
|
||||
else: # If there is no mesh_data, use a bounding box that encompasses the local (0,0,0)
|
||||
position = self.getWorldPosition()
|
||||
self._aabb = AxisAlignedBox(minimum=position, maximum=position)
|
||||
self._aabb = AxisAlignedBox(minimum = position, maximum = position)
|
||||
|
||||
for child in self.getAllChildren():
|
||||
if child.callDecoration("isNonPrintingMesh"):
|
||||
|
@ -122,10 +135,11 @@ class CuraSceneNode(SceneNode):
|
|||
else:
|
||||
self._aabb = self._aabb + child.getBoundingBox()
|
||||
|
||||
## Taken from SceneNode, but replaced SceneNode with CuraSceneNode
|
||||
def __deepcopy__(self, memo: Dict[int, object]) -> "CuraSceneNode":
|
||||
"""Taken from SceneNode, but replaced SceneNode with CuraSceneNode"""
|
||||
|
||||
copy = CuraSceneNode(no_setting_override = True) # Setting override will be added later
|
||||
copy.setTransformation(self.getLocalTransformation())
|
||||
copy.setTransformation(self.getLocalTransformation(copy= False))
|
||||
copy.setMeshData(self._mesh_data)
|
||||
copy.setVisible(cast(bool, deepcopy(self._visible, memo)))
|
||||
copy._selectable = cast(bool, deepcopy(self._selectable, memo))
|
||||
|
|
|
@ -4,7 +4,7 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
|
|||
class SliceableObjectDecorator(SceneNodeDecorator):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
|
||||
def isSliceable(self) -> bool:
|
||||
return True
|
||||
|
||||
|
|
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