mirror of
https://git.mirrors.martin98.com/https://github.com/Ultimaker/Cura
synced 2025-09-27 06:43:14 +08:00
Merge branch 'master' into workaround_kde_qqc2_crash
This commit is contained in:
commit
b800815378
2
.github/ISSUE_TEMPLATE/bug-report.md
vendored
2
.github/ISSUE_TEMPLATE/bug-report.md
vendored
@ -40,7 +40,7 @@ Thank you for using Cura!
|
||||
(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.)
|
||||
(For slicing bugs, provide a project which clearly shows the bug, by going to File->Save Project. For big files you may need to use WeTransfer or similar file sharing sites. G-code files are not project files!)
|
||||
|
||||
**Log file**
|
||||
(See https://github.com/Ultimaker/Cura#logging-issues to find the log file to upload, or copy a relevant snippet from it.)
|
||||
|
13
.github/no-response.yml
vendored
Normal file
13
.github/no-response.yml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
# Configuration for probot-no-response - https://github.com/probot/no-response
|
||||
|
||||
# Number of days of inactivity before an Issue is closed for lack of response
|
||||
daysUntilClose: 14
|
||||
# Label requiring a response
|
||||
responseRequiredLabel: 'Status: Needs Info'
|
||||
# Comment to post when closing an Issue for lack of response. Set to `false` to disable
|
||||
closeComment: >
|
||||
This issue has been automatically closed because there has been no response
|
||||
to our request for more information from the original author. With only the
|
||||
information that is currently in the issue, we don't have enough information
|
||||
to take action. Please reach out if you have or find the answers we need so
|
||||
that we can investigate further.
|
@ -1,5 +1,5 @@
|
||||
---
|
||||
name: CI/CD
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@ -10,11 +10,12 @@ on:
|
||||
pull_request:
|
||||
jobs:
|
||||
build:
|
||||
name: Build and test
|
||||
runs-on: ubuntu-latest
|
||||
container: ultimaker/cura-build-environment
|
||||
steps:
|
||||
- name: Checkout Cura
|
||||
uses: actions/checkout@v2
|
||||
- name: Build and test
|
||||
- name: Build
|
||||
run: docker/build.sh
|
||||
- name: Test
|
||||
run: docker/test.sh
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -53,6 +53,8 @@ plugins/GodMode
|
||||
plugins/OctoPrintPlugin
|
||||
plugins/ProfileFlattener
|
||||
plugins/SettingsGuide
|
||||
plugins/SettingsGuide2
|
||||
plugins/SVGToolpathReader
|
||||
plugins/X3GWriter
|
||||
|
||||
#Build stuff
|
||||
@ -76,3 +78,5 @@ CuraEngine
|
||||
|
||||
#Prevents import failures when plugin running tests
|
||||
plugins/__init__.py
|
||||
|
||||
/venv
|
||||
|
@ -5,8 +5,8 @@ include(GNUInstallDirs)
|
||||
|
||||
list(APPEND CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/cmake)
|
||||
|
||||
set(URANIUM_DIR "${CMAKE_SOURCE_DIR}/../Uranium" CACHE DIRECTORY "The location of the Uranium repository")
|
||||
set(URANIUM_SCRIPTS_DIR "${URANIUM_DIR}/scripts" CACHE DIRECTORY "The location of the scripts directory of the Uranium repository")
|
||||
set(URANIUM_DIR "${CMAKE_SOURCE_DIR}/../Uranium" CACHE PATH "The location of the Uranium repository")
|
||||
set(URANIUM_SCRIPTS_DIR "${URANIUM_DIR}/scripts" CACHE PATH "The location of the scripts directory of the Uranium repository")
|
||||
|
||||
# Tests
|
||||
include(CuraTests)
|
||||
@ -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(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()
|
||||
|
||||
|
||||
|
@ -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
|
||||
------------
|
||||
|
@ -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,15 +1,16 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Optional, Dict, TYPE_CHECKING
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, TYPE_CHECKING, Callable
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty
|
||||
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from cura import UltimakerCloudAuthentication
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
from cura.OAuth2.AuthorizationService import AuthorizationService
|
||||
from cura.OAuth2.Models import OAuth2Settings
|
||||
from cura.UltimakerCloud import UltimakerCloudConstants
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
@ -17,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,
|
||||
@ -56,6 +89,16 @@ class Account(QObject):
|
||||
|
||||
self._authorization_service = AuthorizationService(self._oauth_settings)
|
||||
|
||||
# Create a timer for automatic account sync
|
||||
self._update_timer = QTimer()
|
||||
self._update_timer.setInterval(int(self.SYNC_INTERVAL * 1000))
|
||||
# The timer is restarted explicitly after an update was processed. This prevents 2 concurrent updates
|
||||
self._update_timer.setSingleShot(True)
|
||||
self._update_timer.timeout.connect(self.sync)
|
||||
|
||||
self._sync_services = {} # type: Dict[str, int]
|
||||
"""contains entries "service_name" : SyncState"""
|
||||
|
||||
def initialize(self) -> None:
|
||||
self._authorization_service.initialize(self._application.getPreferences())
|
||||
self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged)
|
||||
@ -63,12 +106,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)
|
||||
@ -83,18 +179,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):
|
||||
@ -114,15 +252,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) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
from UM.Decorators import deprecated
|
||||
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
|
||||
from UM.Logger import Logger
|
||||
from UM.Math.Polygon import Polygon
|
||||
@ -16,21 +17,24 @@ from collections import namedtuple
|
||||
import numpy
|
||||
import copy
|
||||
|
||||
|
||||
## Return object for bestSpot
|
||||
LocationSuggestion = namedtuple("LocationSuggestion", ["x", "y", "penalty_points", "priority"])
|
||||
"""Return object for bestSpot"""
|
||||
|
||||
|
||||
## The Arrange classed is used together with ShapeArray. Use it to find
|
||||
# good locations for objects that you try to put on a build place.
|
||||
# Different priority schemes can be defined so it alters the behavior while using
|
||||
# the same logic.
|
||||
#
|
||||
# Note: Make sure the scale is the same between ShapeArray objects and the Arrange instance.
|
||||
class Arrange:
|
||||
"""
|
||||
The Arrange classed is used together with :py:class:`cura.Arranging.ShapeArray.ShapeArray`. Use it to find good locations for objects that you try to put
|
||||
on a build place. Different priority schemes can be defined so it alters the behavior while using the same logic.
|
||||
|
||||
.. note::
|
||||
|
||||
Make sure the scale is the same between :py:class:`cura.Arranging.ShapeArray.ShapeArray` objects and the :py:class:`cura.Arranging.Arrange.Arrange` instance.
|
||||
"""
|
||||
|
||||
build_volume = None # type: Optional[BuildVolume]
|
||||
|
||||
def __init__(self, x, y, offset_x, offset_y, scale= 0.5):
|
||||
@deprecated("Use the functions in Nest2dArrange instead", "4.8")
|
||||
def __init__(self, x, y, offset_x, offset_y, scale = 0.5):
|
||||
self._scale = scale # convert input coordinates to arrange coordinates
|
||||
world_x, world_y = int(x * self._scale), int(y * self._scale)
|
||||
self._shape = (world_y, world_x)
|
||||
@ -42,14 +46,22 @@ class Arrange:
|
||||
self._last_priority = 0
|
||||
self._is_empty = True
|
||||
|
||||
## Helper to create an Arranger instance
|
||||
#
|
||||
# Either fill in scene_root and create will find all sliceable nodes by itself,
|
||||
# or use fixed_nodes to provide the nodes yourself.
|
||||
# \param scene_root Root for finding all scene nodes
|
||||
# \param fixed_nodes Scene nodes to be placed
|
||||
@classmethod
|
||||
def create(cls, scene_root = None, fixed_nodes = None, scale = 0.5, x = 350, y = 250, min_offset = 8):
|
||||
@deprecated("Use the functions in Nest2dArrange instead", "4.8")
|
||||
def create(cls, scene_root = None, fixed_nodes = None, scale = 0.5, x = 350, y = 250, min_offset = 8) -> "Arrange":
|
||||
"""Helper to create an :py:class:`cura.Arranging.Arrange.Arrange` instance
|
||||
|
||||
Either fill in scene_root and create will find all sliceable nodes by itself, or use fixed_nodes to provide the
|
||||
nodes yourself.
|
||||
|
||||
:param scene_root: Root for finding all scene nodes default = None
|
||||
:param fixed_nodes: Scene nodes to be placed default = None
|
||||
:param scale: default = 0.5
|
||||
:param x: default = 350
|
||||
:param y: default = 250
|
||||
:param min_offset: default = 8
|
||||
"""
|
||||
|
||||
arranger = Arrange(x, y, x // 2, y // 2, scale = scale)
|
||||
arranger.centerFirst()
|
||||
|
||||
@ -71,8 +83,11 @@ class Arrange:
|
||||
# After scaling (like up to 0.1 mm) the node might not have points
|
||||
if not points.size:
|
||||
continue
|
||||
|
||||
shape_arr = ShapeArray.fromPolygon(points, scale = scale)
|
||||
try:
|
||||
shape_arr = ShapeArray.fromPolygon(points, scale = scale)
|
||||
except ValueError:
|
||||
Logger.logException("w", "Unable to create polygon")
|
||||
continue
|
||||
arranger.place(0, 0, shape_arr)
|
||||
|
||||
# If a build volume was set, add the disallowed areas
|
||||
@ -84,16 +99,22 @@ class Arrange:
|
||||
arranger.place(0, 0, shape_arr, update_empty = False)
|
||||
return arranger
|
||||
|
||||
## This resets the optimization for finding location based on size
|
||||
def resetLastPriority(self):
|
||||
"""This resets the optimization for finding location based on size"""
|
||||
|
||||
self._last_priority = 0
|
||||
|
||||
## Find placement for a node (using offset shape) and place it (using hull shape)
|
||||
# return the nodes that should be placed
|
||||
# \param node
|
||||
# \param offset_shape_arr ShapeArray with offset, for placing the shape
|
||||
# \param hull_shape_arr ShapeArray without offset, used to find location
|
||||
def findNodePlacement(self, node: SceneNode, offset_shape_arr: ShapeArray, hull_shape_arr: ShapeArray, step = 1):
|
||||
@deprecated("Use the functions in Nest2dArrange instead", "4.8")
|
||||
def findNodePlacement(self, node: SceneNode, offset_shape_arr: ShapeArray, hull_shape_arr: ShapeArray, step = 1) -> bool:
|
||||
"""Find placement for a node (using offset shape) and place it (using hull shape)
|
||||
|
||||
:param node: The node to be placed
|
||||
:param offset_shape_arr: shape array with offset, for placing the shape
|
||||
:param hull_shape_arr: shape array without offset, used to find location
|
||||
:param step: default = 1
|
||||
:return: the nodes that should be placed
|
||||
"""
|
||||
|
||||
best_spot = self.bestSpot(
|
||||
hull_shape_arr, start_prio = self._last_priority, step = step)
|
||||
x, y = best_spot.x, best_spot.y
|
||||
@ -119,29 +140,32 @@ class Arrange:
|
||||
node.setPosition(Vector(200, center_y, 100))
|
||||
return found_spot
|
||||
|
||||
## Fill priority, center is best. Lower value is better
|
||||
# This is a strategy for the arranger.
|
||||
def centerFirst(self):
|
||||
"""Fill priority, center is best. Lower value is better. """
|
||||
|
||||
# Square distance: creates a more round shape
|
||||
self._priority = numpy.fromfunction(
|
||||
lambda j, i: (self._offset_x - i) ** 2 + (self._offset_y - j) ** 2, self._shape, dtype=numpy.int32)
|
||||
self._priority_unique_values = numpy.unique(self._priority)
|
||||
self._priority_unique_values.sort()
|
||||
|
||||
## Fill priority, back is best. Lower value is better
|
||||
# This is a strategy for the arranger.
|
||||
def backFirst(self):
|
||||
"""Fill priority, back is best. Lower value is better """
|
||||
|
||||
self._priority = numpy.fromfunction(
|
||||
lambda j, i: 10 * j + abs(self._offset_x - i), self._shape, dtype=numpy.int32)
|
||||
self._priority_unique_values = numpy.unique(self._priority)
|
||||
self._priority_unique_values.sort()
|
||||
|
||||
## Return the amount of "penalty points" for polygon, which is the sum of priority
|
||||
# None if occupied
|
||||
# \param x x-coordinate to check shape
|
||||
# \param y y-coordinate
|
||||
# \param shape_arr the ShapeArray object to place
|
||||
def checkShape(self, x, y, shape_arr):
|
||||
def checkShape(self, x, y, shape_arr) -> Optional[numpy.ndarray]:
|
||||
"""Return the amount of "penalty points" for polygon, which is the sum of priority
|
||||
|
||||
:param x: x-coordinate to check shape
|
||||
:param y: y-coordinate to check shape
|
||||
:param shape_arr: the shape array object to place
|
||||
:return: None if occupied
|
||||
"""
|
||||
|
||||
x = int(self._scale * x)
|
||||
y = int(self._scale * y)
|
||||
offset_x = x + self._offset_x + shape_arr.offset_x
|
||||
@ -165,17 +189,24 @@ 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:
|
||||
start_idx = start_idx_list[0][0]
|
||||
try:
|
||||
start_idx = start_idx_list[0][0]
|
||||
except IndexError:
|
||||
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])):
|
||||
@ -189,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
|
||||
|
@ -10,18 +10,24 @@ if TYPE_CHECKING:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
|
||||
## The BackupsManager is responsible for managing the creating and restoring of
|
||||
# back-ups.
|
||||
#
|
||||
# Back-ups themselves are represented in a different class.
|
||||
class BackupsManager:
|
||||
"""
|
||||
The BackupsManager is responsible for managing the creating and restoring of
|
||||
back-ups.
|
||||
|
||||
Back-ups themselves are represented in a different class.
|
||||
"""
|
||||
|
||||
def __init__(self, application: "CuraApplication") -> None:
|
||||
self._application = application
|
||||
|
||||
## Get a back-up of the current configuration.
|
||||
# \return A tuple containing a ZipFile (the actual back-up) and a dict
|
||||
# containing some metadata (like version).
|
||||
def createBackup(self) -> Tuple[Optional[bytes], Optional[Dict[str, str]]]:
|
||||
"""
|
||||
Get a back-up of the current configuration.
|
||||
|
||||
:return: A tuple containing a ZipFile (the actual back-up) and a dict containing some metadata (like version).
|
||||
"""
|
||||
|
||||
self._disableAutoSave()
|
||||
backup = Backup(self._application)
|
||||
backup.makeFromCurrent()
|
||||
@ -29,11 +35,14 @@ class BackupsManager:
|
||||
# We don't return a Backup here because we want plugins only to interact with our API and not full objects.
|
||||
return backup.zip_file, backup.meta_data
|
||||
|
||||
## Restore a back-up from a given ZipFile.
|
||||
# \param zip_file A bytes object containing the actual back-up.
|
||||
# \param meta_data A dict containing some metadata that is needed to
|
||||
# restore the back-up correctly.
|
||||
def restoreBackup(self, zip_file: bytes, meta_data: Dict[str, str]) -> None:
|
||||
"""
|
||||
Restore a back-up from a given ZipFile.
|
||||
|
||||
:param zip_file: A bytes object containing the actual back-up.
|
||||
:param meta_data: A dict containing some metadata that is needed to restore the back-up correctly.
|
||||
"""
|
||||
|
||||
if not meta_data.get("cura_release", None):
|
||||
# If there is no "cura_release" specified in the meta data, we don't execute a backup restore.
|
||||
Logger.log("w", "Tried to restore a backup without specifying a Cura version number.")
|
||||
@ -48,9 +57,10 @@ class BackupsManager:
|
||||
# We don't want to store the data at this point as that would override the just-restored backup.
|
||||
self._application.windowClosed(save_data = False)
|
||||
|
||||
## Here we try to disable the auto-save plug-in as it might interfere with
|
||||
# restoring a back-up.
|
||||
def _disableAutoSave(self) -> None:
|
||||
"""Here we (try to) disable the saving as it might interfere with restoring a back-up."""
|
||||
|
||||
self._application.enableSave(False)
|
||||
auto_save = self._application.getAutoSave()
|
||||
# The auto save is only not created if the application has not yet started.
|
||||
if auto_save:
|
||||
@ -58,8 +68,10 @@ class BackupsManager:
|
||||
else:
|
||||
Logger.log("e", "Unable to disable the autosave as application init has not been completed")
|
||||
|
||||
## Re-enable auto-save after we're done.
|
||||
def _enableAutoSave(self) -> None:
|
||||
"""Re-enable auto-save and other saving after we're done."""
|
||||
|
||||
self._application.enableSave(True)
|
||||
auto_save = self._application.getAutoSave()
|
||||
# The auto save is only not created if the application has not yet started.
|
||||
if auto_save:
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 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,23 @@ 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")
|
||||
adhesion_type = adhesion_override
|
||||
if adhesion_type is None:
|
||||
adhesion_type = container_stack.getProperty("adhesion_type", "value")
|
||||
skirt_brim_line_width = self._global_container_stack.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 +1080,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 +1124,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 +1167,8 @@ class BuildVolume(SceneNode):
|
||||
# Now combine our different pieces of data to get the final border size.
|
||||
# Support expansion is added to the bed adhesion, since the bed adhesion goes around support.
|
||||
# Support expansion is added to farthest shield distance, since the shields go around support.
|
||||
border_size = max(move_from_wall_radius, support_expansion + farthest_shield_distance, support_expansion + bed_adhesion_size)
|
||||
return border_size
|
||||
self._edge_disallowed_size = max(move_from_wall_radius, support_expansion + farthest_shield_distance, support_expansion + bed_adhesion_size)
|
||||
return self._edge_disallowed_size
|
||||
|
||||
def _clamp(self, value, min_value, max_value):
|
||||
return max(min(value, max_value), min_value)
|
||||
@ -1128,10 +1177,10 @@ class BuildVolume(SceneNode):
|
||||
_skirt_settings = ["adhesion_type", "skirt_gap", "skirt_line_count", "skirt_brim_line_width", "brim_width", "brim_line_count", "raft_margin", "draft_shield_enabled", "draft_shield_dist", "initial_layer_line_width_factor"]
|
||||
_raft_settings = ["adhesion_type", "raft_base_thickness", "raft_interface_thickness", "raft_surface_layers", "raft_surface_thickness", "raft_airgap", "layer_0_z_overlap"]
|
||||
_extra_z_settings = ["retraction_hop_enabled", "retraction_hop"]
|
||||
_prime_settings = ["extruder_prime_pos_x", "extruder_prime_pos_y", "extruder_prime_pos_z", "prime_blob_enable"]
|
||||
_prime_settings = ["extruder_prime_pos_x", "extruder_prime_pos_y", "prime_blob_enable"]
|
||||
_tower_settings = ["prime_tower_enable", "prime_tower_size", "prime_tower_position_x", "prime_tower_position_y", "prime_tower_brim_enable"]
|
||||
_ooze_shield_settings = ["ooze_shield_enabled", "ooze_shield_dist"]
|
||||
_distance_settings = ["infill_wipe_dist", "travel_avoid_distance", "support_offset", "support_enable", "travel_avoid_other_parts", "travel_avoid_supports"]
|
||||
_distance_settings = ["infill_wipe_dist", "travel_avoid_distance", "support_offset", "support_enable", "travel_avoid_other_parts", "travel_avoid_supports", "wall_line_count", "wall_line_width_0", "wall_line_width_x"]
|
||||
_extruder_settings = ["support_enable", "support_bottom_enable", "support_roof_enable", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "brim_line_count", "adhesion_extruder_nr", "adhesion_type"] #Settings that can affect which extruders are used.
|
||||
_limit_to_extruder_settings = ["wall_extruder_nr", "wall_0_extruder_nr", "wall_x_extruder_nr", "top_bottom_extruder_nr", "infill_extruder_nr", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "adhesion_extruder_nr"]
|
||||
_disallowed_area_settings = _skirt_settings + _prime_settings + _tower_settings + _ooze_shield_settings + _distance_settings + _extruder_settings
|
||||
|
@ -10,7 +10,7 @@ import os.path
|
||||
import uuid
|
||||
import json
|
||||
import locale
|
||||
from typing import cast
|
||||
from typing import cast, Any
|
||||
|
||||
try:
|
||||
from sentry_sdk.hub import Hub
|
||||
@ -32,6 +32,8 @@ from UM.Resources import Resources
|
||||
from cura import ApplicationMetadata
|
||||
|
||||
catalog = i18nCatalog("cura")
|
||||
home_dir = os.path.expanduser("~")
|
||||
|
||||
|
||||
MYPY = False
|
||||
if MYPY:
|
||||
@ -83,6 +85,21 @@ class CrashHandler:
|
||||
self.dialog = QDialog()
|
||||
self._createDialog()
|
||||
|
||||
@staticmethod
|
||||
def pruneSensitiveData(obj: Any) -> Any:
|
||||
if isinstance(obj, str):
|
||||
return obj.replace("\\\\", "\\").replace(home_dir, "<user_home>")
|
||||
if isinstance(obj, list):
|
||||
return [CrashHandler.pruneSensitiveData(item) for item in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {k: CrashHandler.pruneSensitiveData(v) for k, v in obj.items()}
|
||||
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def sentryBeforeSend(event, hint):
|
||||
return CrashHandler.pruneSensitiveData(event)
|
||||
|
||||
def _createEarlyCrashDialog(self):
|
||||
dialog = QDialog()
|
||||
dialog.setMinimumWidth(500)
|
||||
@ -133,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()
|
||||
|
||||
@ -145,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"))
|
||||
@ -161,7 +180,6 @@ class CrashHandler:
|
||||
layout.addWidget(self._informationWidget())
|
||||
layout.addWidget(self._exceptionInfoWidget())
|
||||
layout.addWidget(self._logInfoWidget())
|
||||
layout.addWidget(self._userDescriptionWidget())
|
||||
layout.addWidget(self._buttonsWidget())
|
||||
|
||||
def _close(self):
|
||||
@ -197,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/>"
|
||||
@ -219,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
|
||||
|
||||
@ -374,21 +407,6 @@ class CrashHandler:
|
||||
|
||||
return group
|
||||
|
||||
def _userDescriptionWidget(self):
|
||||
group = QGroupBox()
|
||||
group.setTitle(catalog.i18nc("@title:groupbox", "User description" +
|
||||
" (Note: Developers may not speak your language, please use English if possible)"))
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# When sending the report, the user comments will be collected
|
||||
self.user_description_text_area = QTextEdit()
|
||||
self.user_description_text_area.setFocus(True)
|
||||
|
||||
layout.addWidget(self.user_description_text_area)
|
||||
group.setLayout(layout)
|
||||
|
||||
return group
|
||||
|
||||
def _buttonsWidget(self):
|
||||
buttons = QDialogButtonBox()
|
||||
buttons.addButton(QDialogButtonBox.Close)
|
||||
@ -403,9 +421,6 @@ class CrashHandler:
|
||||
return buttons
|
||||
|
||||
def _sendCrashReport(self):
|
||||
# Before sending data, the user comments are stored
|
||||
self.data["user_info"] = self.user_description_text_area.toPlainText()
|
||||
|
||||
if with_sentry_sdk:
|
||||
try:
|
||||
hub = Hub.current
|
||||
|
@ -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 = []
|
||||
|
@ -4,82 +4,68 @@
|
||||
import os
|
||||
import sys
|
||||
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
|
||||
from PyQt5.QtGui import QColor, QIcon
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
from PyQt5.QtQml import qmlRegisterUncreatableType, qmlRegisterSingletonType, qmlRegisterType
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
import UM.Util
|
||||
import cura.Settings.cura_empty_instance_containers
|
||||
from UM.Application import Application
|
||||
from UM.Decorators import override
|
||||
from UM.FlameProfiler import pyqtSlot
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.Platform import Platform
|
||||
from UM.PluginError import PluginNotFoundError
|
||||
from UM.Resources import Resources
|
||||
from UM.Preferences import Preferences
|
||||
from UM.Qt.QtApplication import QtApplication # The class we're inheriting from.
|
||||
import UM.Util
|
||||
from UM.View.SelectionPass import SelectionPass # For typing.
|
||||
|
||||
from UM.Math.AxisAlignedBox import AxisAlignedBox
|
||||
from UM.Math.Matrix import Matrix
|
||||
from UM.Math.Quaternion import Quaternion
|
||||
from UM.Math.Vector import Vector
|
||||
|
||||
from UM.Mesh.ReadMeshJob import ReadMeshJob
|
||||
|
||||
from UM.Message import Message
|
||||
from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
|
||||
from UM.Operations.GroupedOperation import GroupedOperation
|
||||
from UM.Operations.SetTransformOperation import SetTransformOperation
|
||||
|
||||
from UM.Platform import Platform
|
||||
from UM.PluginError import PluginNotFoundError
|
||||
from UM.Preferences import Preferences
|
||||
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
|
||||
from UM.Settings.InstanceContainer import InstanceContainer
|
||||
from UM.Settings.SettingDefinition import SettingDefinition, DefinitionPropertyType
|
||||
from UM.Settings.SettingFunction import SettingFunction
|
||||
from UM.Settings.Validator import Validator
|
||||
|
||||
from UM.View.SelectionPass import SelectionPass # For typing.
|
||||
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.ArrangeObjectsJob import ArrangeObjectsJob
|
||||
from cura.Arranging.ArrangeObjectsAllBuildPlatesJob import ArrangeObjectsAllBuildPlatesJob
|
||||
from cura.Arranging.ShapeArray import ShapeArray
|
||||
|
||||
from cura.Operations.SetParentOperation import SetParentOperation
|
||||
|
||||
from cura.Scene.BlockSlicingDecorator import BlockSlicingDecorator
|
||||
from cura.Scene.BuildPlateDecorator import BuildPlateDecorator
|
||||
from cura.Scene.ConvexHullDecorator import ConvexHullDecorator
|
||||
from cura.Scene.CuraSceneController import CuraSceneController
|
||||
from cura.Scene.CuraSceneNode import CuraSceneNode
|
||||
|
||||
from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator
|
||||
from cura.Scene import ZOffsetDecorator
|
||||
from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob
|
||||
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
|
||||
from cura.Machines.Models.GenericMaterialsModel import GenericMaterialsModel
|
||||
from cura.Machines.Models.GlobalStacksModel import GlobalStacksModel
|
||||
from cura.Machines.Models.IntentCategoryModel import IntentCategoryModel
|
||||
from cura.Machines.Models.IntentModel import IntentModel
|
||||
from cura.Machines.Models.MaterialBrandsModel import MaterialBrandsModel
|
||||
from cura.Machines.Models.MaterialManagementModel import MaterialManagementModel
|
||||
from cura.Machines.Models.MultiBuildPlateModel import MultiBuildPlateModel
|
||||
@ -89,51 +75,47 @@ from cura.Machines.Models.QualityProfilesDropDownMenuModel import QualityProfile
|
||||
from cura.Machines.Models.QualitySettingsModel import QualitySettingsModel
|
||||
from cura.Machines.Models.SettingVisibilityPresetsModel import SettingVisibilityPresetsModel
|
||||
from cura.Machines.Models.UserChangesModel import UserChangesModel
|
||||
from cura.Machines.Models.IntentModel import IntentModel
|
||||
from cura.Machines.Models.IntentCategoryModel import IntentCategoryModel
|
||||
|
||||
from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
|
||||
from cura.Operations.SetParentOperation import SetParentOperation
|
||||
from cura.PrinterOutput.NetworkMJPGImage import NetworkMJPGImage
|
||||
|
||||
import cura.Settings.cura_empty_instance_containers
|
||||
from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
|
||||
from cura.Scene import ZOffsetDecorator
|
||||
from cura.Scene.BlockSlicingDecorator import BlockSlicingDecorator
|
||||
from cura.Scene.BuildPlateDecorator import BuildPlateDecorator
|
||||
from cura.Scene.ConvexHullDecorator import ConvexHullDecorator
|
||||
from cura.Scene.CuraSceneController import CuraSceneController
|
||||
from cura.Scene.CuraSceneNode import CuraSceneNode
|
||||
from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator
|
||||
from cura.Settings.ContainerManager import ContainerManager
|
||||
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
|
||||
from cura.Settings.CuraFormulaFunctions import CuraFormulaFunctions
|
||||
from cura.Settings.ExtruderManager import ExtruderManager
|
||||
from cura.Settings.ExtruderStack import ExtruderStack
|
||||
from cura.Settings.GlobalStack import GlobalStack
|
||||
from cura.Settings.IntentManager import IntentManager
|
||||
from cura.Settings.MachineManager import MachineManager
|
||||
from cura.Settings.MachineNameValidator import MachineNameValidator
|
||||
from cura.Settings.IntentManager import IntentManager
|
||||
from cura.Settings.MaterialSettingsVisibilityHandler import MaterialSettingsVisibilityHandler
|
||||
from cura.Settings.SettingInheritanceManager import SettingInheritanceManager
|
||||
from cura.Settings.SidebarCustomMenuItemsModel import SidebarCustomMenuItemsModel
|
||||
from cura.Settings.SimpleModeSettingsManager import SimpleModeSettingsManager
|
||||
|
||||
from cura.TaskManagement.OnExitCallbackManager import OnExitCallbackManager
|
||||
|
||||
from cura.UI import CuraSplashScreen, MachineActionManager, PrintInformation
|
||||
from cura.UI.AddPrinterPagesModel import AddPrinterPagesModel
|
||||
from cura.UI.MachineSettingsManager import MachineSettingsManager
|
||||
from cura.UI.ObjectsModel import ObjectsModel
|
||||
from cura.UI.TextManager import TextManager
|
||||
from cura.UI.AddPrinterPagesModel import AddPrinterPagesModel
|
||||
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 UltimakerCloudConstants
|
||||
from cura.Utils.NetworkingUtil import NetworkingUtil
|
||||
|
||||
from .SingleInstance import SingleInstance
|
||||
from .AutoSave import AutoSave
|
||||
from . import PlatformPhysics
|
||||
from . import BuildVolume
|
||||
from . import CameraAnimation
|
||||
from . import CuraActions
|
||||
from . import PlatformPhysics
|
||||
from . import PrintJobPreviewImageProvider
|
||||
|
||||
from cura.TaskManagement.OnExitCallbackManager import OnExitCallbackManager
|
||||
|
||||
from cura import ApplicationMetadata, UltimakerCloudAuthentication
|
||||
from cura.Settings.GlobalStack import GlobalStack
|
||||
from .AutoSave import AutoSave
|
||||
from .SingleInstance import SingleInstance
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from UM.Settings.EmptyInstanceContainer import EmptyInstanceContainer
|
||||
@ -145,7 +127,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 = 11
|
||||
SettingVersion = 16
|
||||
|
||||
Created = False
|
||||
|
||||
@ -222,9 +204,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)
|
||||
|
||||
@ -263,6 +247,7 @@ class CuraApplication(QtApplication):
|
||||
|
||||
# Backups
|
||||
self._auto_save = None # type: Optional[AutoSave]
|
||||
self._enable_save = True
|
||||
|
||||
self._container_registry_class = CuraContainerRegistry
|
||||
# Redefined here in order to please the typing.
|
||||
@ -272,15 +257,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",
|
||||
@ -322,6 +314,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()
|
||||
@ -342,8 +337,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)
|
||||
@ -385,9 +381,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)
|
||||
@ -397,8 +396,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")
|
||||
@ -423,8 +423,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.
|
||||
@ -449,9 +450,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"),
|
||||
@ -466,12 +468,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.:
|
||||
@ -523,7 +529,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")
|
||||
|
||||
@ -627,12 +633,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.
|
||||
@ -646,14 +653,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 = "",
|
||||
@ -706,15 +712,20 @@ class CuraApplication(QtApplication):
|
||||
self._message_box_callback = None
|
||||
self._message_box_callback_arguments = []
|
||||
|
||||
def enableSave(self, enable: bool):
|
||||
self._enable_save = enable
|
||||
|
||||
# Cura has multiple locations where instance containers need to be saved, so we need to handle this differently.
|
||||
def saveSettings(self) -> None:
|
||||
if not self.started:
|
||||
if not self.started or not self._enable_save:
|
||||
# Do not do saving during application start or when data should not be saved on quit.
|
||||
return
|
||||
ContainerRegistry.getInstance().saveDirtyContainers()
|
||||
self.savePreferences()
|
||||
|
||||
def saveStack(self, stack):
|
||||
if not self._enable_save:
|
||||
return
|
||||
ContainerRegistry.getInstance().saveContainer(stack)
|
||||
|
||||
@pyqtSlot(str, result = QUrl)
|
||||
@ -726,9 +737,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)
|
||||
@ -742,8 +756,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()
|
||||
|
||||
@ -752,9 +765,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:
|
||||
@ -786,6 +802,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
|
||||
@ -801,6 +819,7 @@ 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()
|
||||
|
||||
# Detect in which mode to run and execute that mode
|
||||
@ -838,13 +857,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()
|
||||
@ -873,8 +895,9 @@ class CuraApplication(QtApplication):
|
||||
|
||||
# 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, 100, 0))
|
||||
camera_tool.setZoomRange(0.1, 2000)
|
||||
|
||||
# Initialize camera animations
|
||||
self._camera_animation = CameraAnimation.CameraAnimation()
|
||||
@ -901,6 +924,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:
|
||||
@ -921,6 +948,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
|
||||
@ -994,10 +1025,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)
|
||||
@ -1017,8 +1051,9 @@ class CuraApplication(QtApplication):
|
||||
self._simple_mode_settings_manager = SimpleModeSettingsManager()
|
||||
return self._simple_mode_settings_manager
|
||||
|
||||
## Handle Qt events
|
||||
def event(self, event):
|
||||
"""Handle Qt events"""
|
||||
|
||||
if event.type() == QEvent.FileOpen:
|
||||
if self._plugins_loaded:
|
||||
self._openFile(event.file())
|
||||
@ -1030,8 +1065,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):
|
||||
@ -1047,10 +1083,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
|
||||
@ -1099,6 +1137,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,
|
||||
@ -1121,6 +1160,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")))
|
||||
@ -1184,8 +1224,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
|
||||
@ -1229,9 +1270,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
|
||||
|
||||
@ -1250,9 +1292,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()):
|
||||
@ -1278,9 +1321,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()):
|
||||
@ -1306,9 +1350,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):
|
||||
@ -1337,6 +1382,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
|
||||
@ -1358,20 +1404,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
|
||||
@ -1397,22 +1451,29 @@ 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:
|
||||
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()
|
||||
|
||||
@pyqtSlot("QStringList")
|
||||
def setExpandedCategories(self, categories: List[str]) -> None:
|
||||
categories = list(set(categories))
|
||||
@ -1461,21 +1522,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":
|
||||
@ -1587,13 +1646,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
|
||||
@ -1624,12 +1701,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)
|
||||
@ -1644,10 +1724,13 @@ class CuraApplication(QtApplication):
|
||||
|
||||
@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):
|
||||
"""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.
|
||||
"""
|
||||
Logger.log("i", "Attempting to read file %s", file.toString())
|
||||
if not file.isValid():
|
||||
return
|
||||
|
||||
@ -1716,8 +1799,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]
|
||||
@ -1731,17 +1820,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
|
||||
@ -1749,8 +1842,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)
|
||||
@ -1781,19 +1874,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
|
||||
@ -1812,7 +1901,10 @@ 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")
|
||||
self.fileCompleted.emit(file_name)
|
||||
|
||||
def addNonSliceableExtension(self, extension):
|
||||
@ -1820,9 +1912,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:
|
||||
@ -1840,7 +1931,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
|
||||
@ -107,10 +109,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 +124,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,18 +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
|
||||
@ -23,7 +26,7 @@ class QualitySettingsModel(ListModel):
|
||||
|
||||
GLOBAL_STACK_POSITION = -1
|
||||
|
||||
def __init__(self, parent = None):
|
||||
def __init__(self, parent = None) -> None:
|
||||
super().__init__(parent = parent)
|
||||
|
||||
self.addRoleName(self.KeyRole, "key")
|
||||
@ -38,7 +41,9 @@ class QualitySettingsModel(ListModel):
|
||||
self._application = cura.CuraApplication.CuraApplication.getInstance()
|
||||
self._application.getMachineManager().activeStackChanged.connect(self._update)
|
||||
|
||||
self._selected_position = self.GLOBAL_STACK_POSITION #Must be either GLOBAL_STACK_POSITION or an extruder position (0, 1, etc.)
|
||||
# Must be either GLOBAL_STACK_POSITION or an extruder position (0, 1, etc.)
|
||||
self._selected_position = self.GLOBAL_STACK_POSITION
|
||||
|
||||
self._selected_quality_item = None # The selected quality in the quality management page
|
||||
self._i18n_catalog = None
|
||||
|
||||
@ -47,14 +52,14 @@ class QualitySettingsModel(ListModel):
|
||||
selectedPositionChanged = pyqtSignal()
|
||||
selectedQualityItemChanged = pyqtSignal()
|
||||
|
||||
def setSelectedPosition(self, selected_position):
|
||||
def setSelectedPosition(self, selected_position: int) -> None:
|
||||
if selected_position != self._selected_position:
|
||||
self._selected_position = selected_position
|
||||
self.selectedPositionChanged.emit()
|
||||
self._update()
|
||||
|
||||
@pyqtProperty(int, fset = setSelectedPosition, notify = selectedPositionChanged)
|
||||
def selectedPosition(self):
|
||||
def selectedPosition(self) -> int:
|
||||
return self._selected_position
|
||||
|
||||
def setSelectedQualityItem(self, selected_quality_item):
|
||||
@ -67,7 +72,7 @@ class QualitySettingsModel(ListModel):
|
||||
def selectedQualityItem(self):
|
||||
return self._selected_quality_item
|
||||
|
||||
def _update(self):
|
||||
def _update(self) -> None:
|
||||
Logger.log("d", "Updating {model_class_name}.".format(model_class_name = self.__class__.__name__))
|
||||
|
||||
if not self._selected_quality_item:
|
||||
@ -79,11 +84,17 @@ 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"]
|
||||
|
||||
quality_node = None
|
||||
settings_keys = set()
|
||||
settings_keys = set() # type: Set[str]
|
||||
if quality_group:
|
||||
if self._selected_position == self.GLOBAL_STACK_POSITION:
|
||||
quality_node = quality_group.node_for_global
|
||||
@ -98,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()}
|
||||
@ -149,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,10 +51,13 @@ 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":
|
||||
"""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.")
|
||||
data = {
|
||||
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
|
||||
@ -64,10 +72,13 @@ class AuthorizationHelpers:
|
||||
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server")
|
||||
|
||||
@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,10 +100,13 @@ 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 = {
|
||||
"Authorization": "Bearer {}".format(access_token)
|
||||
@ -108,23 +122,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:
|
||||
|
@ -3,33 +3,34 @@
|
||||
|
||||
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
|
||||
@ -203,13 +244,14 @@ class AuthorizationService:
|
||||
except ValueError:
|
||||
Logger.logException("w", "Could not load auth data from preferences")
|
||||
|
||||
## Store authentication data in preferences.
|
||||
def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None:
|
||||
"""Store authentication data in preferences."""
|
||||
|
||||
Logger.log("d", "Attempting to store the auth data")
|
||||
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:
|
||||
|
@ -1,9 +1,11 @@
|
||||
# Copyright (c) 2015 Ultimaker B.V.
|
||||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from PyQt5.QtCore import QTimer
|
||||
from shapely.errors import TopologicalError # To capture errors if Shapely messes up.
|
||||
|
||||
from UM.Application import Application
|
||||
from UM.Logger import Logger
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator
|
||||
from UM.Math.Vector import Vector
|
||||
@ -93,15 +95,15 @@ class PlatformPhysics:
|
||||
# Ignore root, ourselves and anything that is not a normal SceneNode.
|
||||
if other_node is root or not issubclass(type(other_node), SceneNode) or other_node is node or other_node.callDecoration("getBuildPlateNumber") != node.callDecoration("getBuildPlateNumber"):
|
||||
continue
|
||||
|
||||
|
||||
# Ignore collisions of a group with it's own children
|
||||
if other_node in node.getAllChildren() or node in other_node.getAllChildren():
|
||||
continue
|
||||
|
||||
|
||||
# Ignore collisions within a group
|
||||
if other_node.getParent() and node.getParent() and (other_node.getParent().callDecoration("isGroup") is not None or node.getParent().callDecoration("isGroup") is not None):
|
||||
continue
|
||||
|
||||
|
||||
# Ignore nodes that do not have the right properties set.
|
||||
if not other_node.callDecoration("getConvexHull") or not other_node.getBoundingBox():
|
||||
continue
|
||||
@ -136,7 +138,11 @@ class PlatformPhysics:
|
||||
own_convex_hull = node.callDecoration("getConvexHull")
|
||||
other_convex_hull = other_node.callDecoration("getConvexHull")
|
||||
if own_convex_hull and other_convex_hull:
|
||||
overlap = own_convex_hull.translate(move_vector.x, move_vector.z).intersectsPolygon(other_convex_hull)
|
||||
try:
|
||||
overlap = own_convex_hull.translate(move_vector.x, move_vector.z).intersectsPolygon(other_convex_hull)
|
||||
except TopologicalError as e: # Can happen if the convex hull is degenerate?
|
||||
Logger.warning("Got a topological error when calculating convex hull intersection: {err}".format(err = str(e)))
|
||||
overlap = False
|
||||
if overlap: # Moving ensured that overlap was still there. Try anew!
|
||||
temp_move_vector = move_vector.set(x = move_vector.x + overlap[0] * self._move_factor,
|
||||
z = move_vector.z + overlap[1] * self._move_factor)
|
||||
@ -175,7 +181,7 @@ class PlatformPhysics:
|
||||
|
||||
if tool.getPluginId() == "TranslateTool":
|
||||
for node in Selection.getAllSelectedObjects():
|
||||
if node.getBoundingBox().bottom < 0:
|
||||
if node.getBoundingBox() and node.getBoundingBox().bottom < 0:
|
||||
if not node.getDecorator(ZOffsetDecorator.ZOffsetDecorator):
|
||||
node.addDecorator(ZOffsetDecorator.ZOffsetDecorator())
|
||||
|
||||
|
@ -1,10 +1,11 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from typing import Optional, TYPE_CHECKING, cast
|
||||
from typing import Optional, TYPE_CHECKING, cast, List
|
||||
|
||||
|
||||
from UM.Application import Application
|
||||
from UM.Logger import Logger
|
||||
from UM.Resources import Resources
|
||||
|
||||
from UM.View.RenderPass import RenderPass
|
||||
@ -20,9 +21,14 @@ if TYPE_CHECKING:
|
||||
from UM.Scene.Camera import Camera
|
||||
|
||||
|
||||
# Make color brighter by normalizing it (maximum factor 2.5 brighter)
|
||||
# color_list is a list of 4 elements: [r, g, b, a], each element is a float 0..1
|
||||
def prettier_color(color_list):
|
||||
def prettier_color(color_list: List[float]) -> List[float]:
|
||||
"""Make color brighter by normalizing
|
||||
|
||||
maximum factor 2.5 brighter
|
||||
|
||||
:param color_list: a list of 4 elements: [r, g, b, a], each element is a float 0..1
|
||||
:return: a normalized list of 4 elements: [r, g, b, a], each element is a float 0..1
|
||||
"""
|
||||
maximum = max(color_list[:3])
|
||||
if maximum > 0:
|
||||
factor = min(1 / maximum, 2.5)
|
||||
@ -31,11 +37,14 @@ def prettier_color(color_list):
|
||||
return [min(i * factor, 1.0) for i in color_list]
|
||||
|
||||
|
||||
## A render pass subclass that renders slicable objects with default parameters.
|
||||
# It uses the active camera by default, but it can be overridden to use a different camera.
|
||||
#
|
||||
# This is useful to get a preview image of a scene taken from a different location as the active camera.
|
||||
class PreviewPass(RenderPass):
|
||||
"""A :py:class:`Uranium.UM.View.RenderPass` subclass that renders slicable objects with default parameters.
|
||||
|
||||
It uses the active camera by default, but it can be overridden to use a different camera.
|
||||
|
||||
This is useful to get a preview image of a scene taken from a different location as the active camera.
|
||||
"""
|
||||
|
||||
def __init__(self, width: int, height: int) -> None:
|
||||
super().__init__("preview", width, height, 0)
|
||||
|
||||
@ -61,11 +70,14 @@ class PreviewPass(RenderPass):
|
||||
self._shader.setUniformValue("u_ambientColor", [0.1, 0.1, 0.1, 1.0])
|
||||
self._shader.setUniformValue("u_specularColor", [0.6, 0.6, 0.6, 1.0])
|
||||
self._shader.setUniformValue("u_shininess", 20.0)
|
||||
self._shader.setUniformValue("u_renderError", 0.0) # We don't want any error markers!.
|
||||
self._shader.setUniformValue("u_faceId", -1) # Don't render any selected faces in the preview.
|
||||
else:
|
||||
Logger.error("Unable to compile shader program: overhang.shader")
|
||||
|
||||
if not self._non_printing_shader:
|
||||
self._non_printing_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader"))
|
||||
if self._non_printing_shader:
|
||||
self._non_printing_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader"))
|
||||
self._non_printing_shader.setUniformValue("u_diffuseColor", [0.5, 0.5, 0.5, 0.5])
|
||||
self._non_printing_shader.setUniformValue("u_opacity", 0.6)
|
||||
|
||||
@ -102,12 +114,12 @@ class PreviewPass(RenderPass):
|
||||
1.0]
|
||||
uniforms["diffuse_color"] = prettier_color(diffuse_color)
|
||||
uniforms["diffuse_color_2"] = diffuse_color2
|
||||
batch_support_mesh.addItem(node.getWorldTransformation(), node.getMeshData(), uniforms = uniforms)
|
||||
batch_support_mesh.addItem(node.getWorldTransformation(copy = False), node.getMeshData(), uniforms = uniforms)
|
||||
else:
|
||||
# Normal scene node
|
||||
uniforms = {}
|
||||
uniforms["diffuse_color"] = prettier_color(cast(CuraSceneNode, node).getDiffuseColor())
|
||||
batch.addItem(node.getWorldTransformation(), node.getMeshData(), uniforms = uniforms)
|
||||
batch.addItem(node.getWorldTransformation(copy = False), node.getMeshData(), uniforms = uniforms)
|
||||
|
||||
self.bind()
|
||||
|
||||
|
@ -10,8 +10,14 @@ class PrintJobPreviewImageProvider(QQuickImageProvider):
|
||||
def __init__(self):
|
||||
super().__init__(QQuickImageProvider.Image)
|
||||
|
||||
## Request a new image.
|
||||
def requestImage(self, id: str, size: QSize) -> Tuple[QImage, QSize]:
|
||||
"""Request a new image.
|
||||
|
||||
:param id: id of the requested image
|
||||
:param size: is not used defaults to QSize(15, 15)
|
||||
:return: an tuple containing the image and size
|
||||
"""
|
||||
|
||||
# The id will have an uuid and an increment separated by a slash. As we don't care about the value of the
|
||||
# increment, we need to strip that first.
|
||||
uuid = id[id.find("/") + 1:]
|
||||
|
@ -7,6 +7,8 @@ from enum import IntEnum
|
||||
from threading import Thread
|
||||
from typing import Union
|
||||
|
||||
from UM.Logger import Logger
|
||||
|
||||
MYPY = False
|
||||
if MYPY:
|
||||
from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
|
||||
@ -33,15 +35,22 @@ class FirmwareUpdater(QObject):
|
||||
else:
|
||||
self._firmware_file = firmware_file
|
||||
|
||||
self._setFirmwareUpdateState(FirmwareUpdateState.updating)
|
||||
if self._firmware_file == "":
|
||||
self._setFirmwareUpdateState(FirmwareUpdateState.firmware_not_found_error)
|
||||
return
|
||||
|
||||
self._update_firmware_thread.start()
|
||||
self._setFirmwareUpdateState(FirmwareUpdateState.updating)
|
||||
try:
|
||||
self._update_firmware_thread.start()
|
||||
except RuntimeError:
|
||||
Logger.warning("Could not start the update thread, since it's still running!")
|
||||
|
||||
def _updateFirmware(self) -> None:
|
||||
raise NotImplementedError("_updateFirmware needs to be implemented")
|
||||
|
||||
## Cleanup after a succesful update
|
||||
def _cleanupAfterUpdate(self) -> None:
|
||||
"""Cleanup after a succesful update"""
|
||||
|
||||
# Clean up for next attempt.
|
||||
self._update_firmware_thread = Thread(target=self._updateFirmware, daemon=True, name = "FirmwareUpdateThread")
|
||||
self._firmware_file = ""
|
||||
|
@ -47,10 +47,13 @@ class ExtruderConfigurationModel(QObject):
|
||||
def hotendID(self) -> Optional[str]:
|
||||
return self._hotend_id
|
||||
|
||||
## This method is intended to indicate whether the configuration is valid or not.
|
||||
# The method checks if the mandatory fields are or not set
|
||||
# At this moment is always valid since we allow to have empty material and variants.
|
||||
def isValid(self) -> bool:
|
||||
"""This method is intended to indicate whether the configuration is valid or not.
|
||||
|
||||
The method checks if the mandatory fields are or not set
|
||||
At this moment is always valid since we allow to have empty material and variants.
|
||||
"""
|
||||
|
||||
return True
|
||||
|
||||
def __str__(self) -> str:
|
||||
@ -71,11 +74,11 @@ class ExtruderConfigurationModel(QObject):
|
||||
# Empty materials should be ignored for comparison
|
||||
if self.activeMaterial is not None and other.activeMaterial is not None:
|
||||
if self.activeMaterial.guid != other.activeMaterial.guid:
|
||||
if self.activeMaterial.guid != "" and other.activeMaterial.guid != "":
|
||||
return False
|
||||
else:
|
||||
if self.activeMaterial.guid == "" and other.activeMaterial.guid == "":
|
||||
# At this point there is no material, so it doesn't matter what the hotend is.
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
if self.hotendID != other.hotendID:
|
||||
return False
|
||||
|
@ -54,8 +54,9 @@ class ExtruderOutputModel(QObject):
|
||||
def updateActiveMaterial(self, material: Optional["MaterialOutputModel"]) -> None:
|
||||
self._extruder_configuration.setMaterial(material)
|
||||
|
||||
## Update the hotend temperature. This only changes it locally.
|
||||
def updateHotendTemperature(self, temperature: float) -> None:
|
||||
"""Update the hotend temperature. This only changes it locally."""
|
||||
|
||||
if self._hotend_temperature != temperature:
|
||||
self._hotend_temperature = temperature
|
||||
self.hotendTemperatureChanged.emit()
|
||||
@ -65,9 +66,10 @@ class ExtruderOutputModel(QObject):
|
||||
self._target_hotend_temperature = temperature
|
||||
self.targetHotendTemperatureChanged.emit()
|
||||
|
||||
## Set the target hotend temperature. This ensures that it's actually sent to the remote.
|
||||
@pyqtSlot(float)
|
||||
def setTargetHotendTemperature(self, temperature: float) -> None:
|
||||
"""Set the target hotend temperature. This ensures that it's actually sent to the remote."""
|
||||
|
||||
self._printer.getController().setTargetHotendTemperature(self._printer, self, temperature)
|
||||
self.updateTargetHotendTemperature(temperature)
|
||||
|
||||
@ -97,17 +99,19 @@ class ExtruderOutputModel(QObject):
|
||||
self._is_preheating = pre_heating
|
||||
self.isPreheatingChanged.emit()
|
||||
|
||||
@pyqtProperty(bool, notify=isPreheatingChanged)
|
||||
@pyqtProperty(bool, notify = isPreheatingChanged)
|
||||
def isPreheating(self) -> bool:
|
||||
return self._is_preheating
|
||||
|
||||
## Pre-heats the extruder before printer.
|
||||
#
|
||||
# \param temperature The temperature to heat the extruder to, in degrees
|
||||
# Celsius.
|
||||
# \param duration How long the bed should stay warm, in seconds.
|
||||
@pyqtSlot(float, float)
|
||||
def preheatHotend(self, temperature: float, duration: float) -> None:
|
||||
"""Pre-heats the extruder before printer.
|
||||
|
||||
:param temperature: The temperature to heat the extruder to, in degrees
|
||||
Celsius.
|
||||
:param duration: How long the bed should stay warm, in seconds.
|
||||
"""
|
||||
|
||||
self._printer._controller.preheatHotend(self, temperature, duration)
|
||||
|
||||
@pyqtSlot()
|
||||
|
@ -48,9 +48,11 @@ class PrinterConfigurationModel(QObject):
|
||||
def buildplateConfiguration(self) -> str:
|
||||
return self._buildplate_configuration
|
||||
|
||||
## This method is intended to indicate whether the configuration is valid or not.
|
||||
# The method checks if the mandatory fields are or not set
|
||||
def isValid(self) -> bool:
|
||||
"""This method is intended to indicate whether the configuration is valid or not.
|
||||
|
||||
The method checks if the mandatory fields are or not set
|
||||
"""
|
||||
if not self._extruder_configurations:
|
||||
return False
|
||||
for configuration in self._extruder_configurations:
|
||||
@ -97,9 +99,11 @@ class PrinterConfigurationModel(QObject):
|
||||
|
||||
return True
|
||||
|
||||
## The hash function is used to compare and create unique sets. The configuration is unique if the configuration
|
||||
# of the extruders is unique (the order of the extruders matters), and the type and buildplate is the same.
|
||||
def __hash__(self):
|
||||
"""The hash function is used to compare and create unique sets. The configuration is unique if the configuration
|
||||
|
||||
of the extruders is unique (the order of the extruders matters), and the type and buildplate is the same.
|
||||
"""
|
||||
extruder_hash = hash(0)
|
||||
first_extruder = None
|
||||
for configuration in self._extruder_configurations:
|
||||
|
@ -163,13 +163,15 @@ class PrinterOutputModel(QObject):
|
||||
def moveHead(self, x: float = 0, y: float = 0, z: float = 0, speed: float = 3000) -> None:
|
||||
self._controller.moveHead(self, x, y, z, speed)
|
||||
|
||||
## Pre-heats the heated bed of the printer.
|
||||
#
|
||||
# \param temperature The temperature to heat the bed to, in degrees
|
||||
# Celsius.
|
||||
# \param duration How long the bed should stay warm, in seconds.
|
||||
@pyqtSlot(float, float)
|
||||
def preheatBed(self, temperature: float, duration: float) -> None:
|
||||
"""Pre-heats the heated bed of the printer.
|
||||
|
||||
:param temperature: The temperature to heat the bed to, in degrees
|
||||
Celsius.
|
||||
:param duration: How long the bed should stay warm, in seconds.
|
||||
"""
|
||||
|
||||
self._controller.preheatBed(self, temperature, duration)
|
||||
|
||||
@pyqtSlot()
|
||||
@ -200,8 +202,9 @@ class PrinterOutputModel(QObject):
|
||||
self._unique_name = unique_name
|
||||
self.nameChanged.emit()
|
||||
|
||||
## Update the bed temperature. This only changes it locally.
|
||||
def updateBedTemperature(self, temperature: float) -> None:
|
||||
"""Update the bed temperature. This only changes it locally."""
|
||||
|
||||
if self._bed_temperature != temperature:
|
||||
self._bed_temperature = temperature
|
||||
self.bedTemperatureChanged.emit()
|
||||
@ -211,9 +214,10 @@ class PrinterOutputModel(QObject):
|
||||
self._target_bed_temperature = temperature
|
||||
self.targetBedTemperatureChanged.emit()
|
||||
|
||||
## Set the target bed temperature. This ensures that it's actually sent to the remote.
|
||||
@pyqtSlot(float)
|
||||
def setTargetBedTemperature(self, temperature: float) -> None:
|
||||
"""Set the target bed temperature. This ensures that it's actually sent to the remote."""
|
||||
|
||||
self._controller.setTargetBedTemperature(self, temperature)
|
||||
self.updateTargetBedTemperature(temperature)
|
||||
|
||||
|
@ -32,8 +32,9 @@ class NetworkMJPGImage(QQuickPaintedItem):
|
||||
|
||||
self.setAntialiasing(True)
|
||||
|
||||
## Ensure that close gets called when object is destroyed
|
||||
def __del__(self) -> None:
|
||||
"""Ensure that close gets called when object is destroyed"""
|
||||
|
||||
self.stop()
|
||||
|
||||
|
||||
@ -111,7 +112,7 @@ class NetworkMJPGImage(QQuickPaintedItem):
|
||||
|
||||
if not self._image_reply.isFinished():
|
||||
self._image_reply.close()
|
||||
except Exception as e: # RuntimeError
|
||||
except Exception: # RuntimeError
|
||||
pass # It can happen that the wrapped c++ object is already deleted.
|
||||
|
||||
self._image_reply = None
|
||||
|
@ -84,8 +84,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
def _compressGCode(self) -> Optional[bytes]:
|
||||
self._compressing_gcode = True
|
||||
|
||||
## Mash the data into single string
|
||||
max_chars_per_line = int(1024 * 1024 / 4) # 1/4 MB per line.
|
||||
"""Mash the data into single string"""
|
||||
file_data_bytes_list = []
|
||||
batched_lines = []
|
||||
batched_lines_count = 0
|
||||
@ -145,9 +145,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent)
|
||||
return request
|
||||
|
||||
## This method was only available privately before, but it was actually called from SendMaterialJob.py.
|
||||
# We now have a public equivalent as well. We did not remove the private one as plugins might be using that.
|
||||
def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
|
||||
"""This method was only available privately before, but it was actually called from SendMaterialJob.py.
|
||||
|
||||
We now have a public equivalent as well. We did not remove the private one as plugins might be using that.
|
||||
"""
|
||||
return self._createFormPart(content_header, data, content_type)
|
||||
|
||||
def _createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
|
||||
@ -163,8 +165,9 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
part.setBody(data)
|
||||
return part
|
||||
|
||||
## Convenience function to get the username, either from the cloud or from the OS.
|
||||
def _getUserName(self) -> str:
|
||||
"""Convenience function to get the username, either from the cloud or from the OS."""
|
||||
|
||||
# check first if we are logged in with the Ultimaker Account
|
||||
account = CuraApplication.getInstance().getCuraAPI().account # type: Account
|
||||
if account and account.isLoggedIn:
|
||||
@ -187,15 +190,17 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
self._createNetworkManager()
|
||||
assert (self._manager is not None)
|
||||
|
||||
## Sends a put request to the given path.
|
||||
# \param url: The path after the API prefix.
|
||||
# \param data: The data to be sent in the body
|
||||
# \param content_type: The content type of the body data.
|
||||
# \param on_finished: The function to call when the response is received.
|
||||
# \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
|
||||
def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = "application/json",
|
||||
on_finished: Optional[Callable[[QNetworkReply], None]] = None,
|
||||
on_progress: Optional[Callable[[int, int], None]] = None) -> None:
|
||||
"""Sends a put request to the given path.
|
||||
|
||||
:param url: The path after the API prefix.
|
||||
:param data: The data to be sent in the body
|
||||
:param content_type: The content type of the body data.
|
||||
:param on_finished: The function to call when the response is received.
|
||||
:param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
|
||||
"""
|
||||
self._validateManager()
|
||||
|
||||
request = self._createEmptyRequest(url, content_type = content_type)
|
||||
@ -212,10 +217,12 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
if on_progress is not None:
|
||||
reply.uploadProgress.connect(on_progress)
|
||||
|
||||
## Sends a delete request to the given path.
|
||||
# \param url: The path after the API prefix.
|
||||
# \param on_finished: The function to be call when the response is received.
|
||||
def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
|
||||
"""Sends a delete request to the given path.
|
||||
|
||||
:param url: The path after the API prefix.
|
||||
:param on_finished: The function to be call when the response is received.
|
||||
"""
|
||||
self._validateManager()
|
||||
|
||||
request = self._createEmptyRequest(url)
|
||||
@ -228,10 +235,12 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
reply = self._manager.deleteResource(request)
|
||||
self._registerOnFinishedCallback(reply, on_finished)
|
||||
|
||||
## Sends a get request to the given path.
|
||||
# \param url: The path after the API prefix.
|
||||
# \param on_finished: The function to be call when the response is received.
|
||||
def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
|
||||
"""Sends a get request to the given path.
|
||||
|
||||
:param url: The path after the API prefix.
|
||||
:param on_finished: The function to be call when the response is received.
|
||||
"""
|
||||
self._validateManager()
|
||||
|
||||
request = self._createEmptyRequest(url)
|
||||
@ -244,14 +253,18 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
reply = self._manager.get(request)
|
||||
self._registerOnFinishedCallback(reply, on_finished)
|
||||
|
||||
## Sends a post request to the given path.
|
||||
# \param url: The path after the API prefix.
|
||||
# \param data: The data to be sent in the body
|
||||
# \param on_finished: The function to call when the response is received.
|
||||
# \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
|
||||
def post(self, url: str, data: Union[str, bytes],
|
||||
on_finished: Optional[Callable[[QNetworkReply], None]],
|
||||
on_progress: Optional[Callable[[int, int], None]] = None) -> None:
|
||||
|
||||
"""Sends a post request to the given path.
|
||||
|
||||
:param url: The path after the API prefix.
|
||||
:param data: The data to be sent in the body
|
||||
:param on_finished: The function to call when the response is received.
|
||||
:param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
|
||||
"""
|
||||
|
||||
self._validateManager()
|
||||
|
||||
request = self._createEmptyRequest(url)
|
||||
@ -318,10 +331,13 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
if on_finished is not None:
|
||||
self._onFinishedCallbacks[reply.url().toString() + str(reply.operation())] = on_finished
|
||||
|
||||
## This method checks if the name of the group stored in the definition container is correct.
|
||||
# After updating from 3.2 to 3.3 some group names may be temporary. If there is a mismatch in the name of the group
|
||||
# then all the container stacks are updated, both the current and the hidden ones.
|
||||
def _checkCorrectGroupName(self, device_id: str, group_name: str) -> None:
|
||||
"""This method checks if the name of the group stored in the definition container is correct.
|
||||
|
||||
After updating from 3.2 to 3.3 some group names may be temporary. If there is a mismatch in the name of the group
|
||||
then all the container stacks are updated, both the current and the hidden ones.
|
||||
"""
|
||||
|
||||
global_container_stack = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
active_machine_network_name = CuraApplication.getInstance().getMachineManager().activeMachineNetworkKey()
|
||||
if global_container_stack and device_id == active_machine_network_name:
|
||||
@ -366,32 +382,38 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||
def getProperties(self):
|
||||
return self._properties
|
||||
|
||||
## Get the unique key of this machine
|
||||
# \return key String containing the key of the machine.
|
||||
@pyqtProperty(str, constant = True)
|
||||
def key(self) -> str:
|
||||
"""Get the unique key of this machine
|
||||
|
||||
:return: key String containing the key of the machine.
|
||||
"""
|
||||
return self._id
|
||||
|
||||
## The IP address of the printer.
|
||||
@pyqtProperty(str, constant = True)
|
||||
def address(self) -> str:
|
||||
"""The IP address of the printer."""
|
||||
|
||||
return self._properties.get(b"address", b"").decode("utf-8")
|
||||
|
||||
## Name of the printer (as returned from the ZeroConf properties)
|
||||
@pyqtProperty(str, constant = True)
|
||||
def name(self) -> str:
|
||||
"""Name of the printer (as returned from the ZeroConf properties)"""
|
||||
|
||||
return self._properties.get(b"name", b"").decode("utf-8")
|
||||
|
||||
## Firmware version (as returned from the ZeroConf properties)
|
||||
@pyqtProperty(str, constant = True)
|
||||
def firmwareVersion(self) -> str:
|
||||
"""Firmware version (as returned from the ZeroConf properties)"""
|
||||
|
||||
return self._properties.get(b"firmware_version", b"").decode("utf-8")
|
||||
|
||||
@pyqtProperty(str, constant = True)
|
||||
def printerType(self) -> str:
|
||||
return self._properties.get(b"printer_type", b"Unknown").decode("utf-8")
|
||||
|
||||
## IP adress of this printer
|
||||
@pyqtProperty(str, constant = True)
|
||||
def ipAddress(self) -> str:
|
||||
"""IP adress of this printer"""
|
||||
|
||||
return self._address
|
||||
|
@ -2,15 +2,19 @@
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
|
||||
## Data class that represents a peripheral for a printer.
|
||||
#
|
||||
# Output device plug-ins may specify that the printer has a certain set of
|
||||
# peripherals. This set is then possibly shown in the interface of the monitor
|
||||
# stage.
|
||||
class Peripheral:
|
||||
## Constructs the peripheral.
|
||||
# \param type A unique ID for the type of peripheral.
|
||||
# \param name A human-readable name for the peripheral.
|
||||
"""Data class that represents a peripheral for a printer.
|
||||
|
||||
Output device plug-ins may specify that the printer has a certain set of
|
||||
peripherals. This set is then possibly shown in the interface of the monitor
|
||||
stage.
|
||||
"""
|
||||
|
||||
def __init__(self, peripheral_type: str, name: str) -> None:
|
||||
"""Constructs the peripheral.
|
||||
|
||||
:param peripheral_type: A unique ID for the type of peripheral.
|
||||
:param name: A human-readable name for the peripheral.
|
||||
"""
|
||||
self.type = peripheral_type
|
||||
self.name = name
|
||||
|
@ -24,8 +24,9 @@ if MYPY:
|
||||
i18n_catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
## The current processing state of the backend.
|
||||
class ConnectionState(IntEnum):
|
||||
"""The current processing state of the backend."""
|
||||
|
||||
Closed = 0
|
||||
Connecting = 1
|
||||
Connected = 2
|
||||
@ -40,17 +41,19 @@ class ConnectionType(IntEnum):
|
||||
CloudConnection = 3
|
||||
|
||||
|
||||
## Printer output device adds extra interface options on top of output device.
|
||||
#
|
||||
# The assumption is made the printer is a FDM printer.
|
||||
#
|
||||
# Note that a number of settings are marked as "final". This is because decorators
|
||||
# are not inherited by children. To fix this we use the private counter part of those
|
||||
# functions to actually have the implementation.
|
||||
#
|
||||
# For all other uses it should be used in the same way as a "regular" OutputDevice.
|
||||
@signalemitter
|
||||
class PrinterOutputDevice(QObject, OutputDevice):
|
||||
"""Printer output device adds extra interface options on top of output device.
|
||||
|
||||
The assumption is made the printer is a FDM printer.
|
||||
|
||||
Note that a number of settings are marked as "final". This is because decorators
|
||||
are not inherited by children. To fix this we use the private counter part of those
|
||||
functions to actually have the implementation.
|
||||
|
||||
For all other uses it should be used in the same way as a "regular" OutputDevice.
|
||||
"""
|
||||
|
||||
|
||||
printersChanged = pyqtSignal()
|
||||
connectionStateChanged = pyqtSignal(str)
|
||||
@ -184,26 +187,30 @@ class PrinterOutputDevice(QObject, OutputDevice):
|
||||
if self._monitor_item is None:
|
||||
self._monitor_item = QtApplication.getInstance().createQmlComponent(self._monitor_view_qml_path, {"OutputDevice": self})
|
||||
|
||||
## Attempt to establish connection
|
||||
def connect(self) -> None:
|
||||
"""Attempt to establish connection"""
|
||||
|
||||
self.setConnectionState(ConnectionState.Connecting)
|
||||
self._update_timer.start()
|
||||
|
||||
## Attempt to close the connection
|
||||
def close(self) -> None:
|
||||
"""Attempt to close the connection"""
|
||||
|
||||
self._update_timer.stop()
|
||||
self.setConnectionState(ConnectionState.Closed)
|
||||
|
||||
## Ensure that close gets called when object is destroyed
|
||||
def __del__(self) -> None:
|
||||
"""Ensure that close gets called when object is destroyed"""
|
||||
|
||||
self.close()
|
||||
|
||||
@pyqtProperty(bool, notify = acceptsCommandsChanged)
|
||||
def acceptsCommands(self) -> bool:
|
||||
return self._accepts_commands
|
||||
|
||||
## Set a flag to signal the UI that the printer is not (yet) ready to receive commands
|
||||
def _setAcceptsCommands(self, accepts_commands: bool) -> None:
|
||||
"""Set a flag to signal the UI that the printer is not (yet) ready to receive commands"""
|
||||
|
||||
if self._accepts_commands != accepts_commands:
|
||||
self._accepts_commands = accepts_commands
|
||||
|
||||
@ -241,16 +248,20 @@ class PrinterOutputDevice(QObject, OutputDevice):
|
||||
# At this point there may be non-updated configurations
|
||||
self._updateUniqueConfigurations()
|
||||
|
||||
## Set the device firmware name
|
||||
#
|
||||
# \param name The name of the firmware.
|
||||
def _setFirmwareName(self, name: str) -> None:
|
||||
"""Set the device firmware name
|
||||
|
||||
:param name: The name of the firmware.
|
||||
"""
|
||||
|
||||
self._firmware_name = name
|
||||
|
||||
## Get the name of device firmware
|
||||
#
|
||||
# This name can be used to define device type
|
||||
def getFirmwareName(self) -> Optional[str]:
|
||||
"""Get the name of device firmware
|
||||
|
||||
This name can be used to define device type
|
||||
"""
|
||||
|
||||
return self._firmware_name
|
||||
|
||||
def getFirmwareUpdater(self) -> Optional["FirmwareUpdater"]:
|
||||
|
@ -10,15 +10,19 @@ class NoProfileException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
## A type of plug-ins that reads profiles from a file.
|
||||
#
|
||||
# The profile is then stored as instance container of the type user profile.
|
||||
class ProfileReader(PluginObject):
|
||||
"""A type of plug-ins that reads profiles from a file.
|
||||
|
||||
The profile is then stored as instance container of the type user profile.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
## Read profile data from a file and return a filled profile.
|
||||
#
|
||||
# \return \type{Profile|Profile[]} The profile that was obtained from the file or a list of Profiles.
|
||||
def read(self, file_name):
|
||||
"""Read profile data from a file and return a filled profile.
|
||||
|
||||
:return: :type{Profile|Profile[]} The profile that was obtained from the file or a list of Profiles.
|
||||
"""
|
||||
|
||||
raise NotImplementedError("Profile reader plug-in was not correctly implemented. The read function was not implemented.")
|
||||
|
@ -3,23 +3,29 @@
|
||||
|
||||
from UM.PluginObject import PluginObject
|
||||
|
||||
## Base class for profile writer plugins.
|
||||
#
|
||||
# This class defines a write() function to write profiles to files with.
|
||||
|
||||
class ProfileWriter(PluginObject):
|
||||
## Initialises the profile writer.
|
||||
#
|
||||
# This currently doesn't do anything since the writer is basically static.
|
||||
"""Base class for profile writer plugins.
|
||||
|
||||
This class defines a write() function to write profiles to files with.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialises the profile writer.
|
||||
|
||||
This currently doesn't do anything since the writer is basically static.
|
||||
"""
|
||||
|
||||
super().__init__()
|
||||
|
||||
## Writes a profile to the specified file path.
|
||||
#
|
||||
# The profile writer may write its own file format to the specified file.
|
||||
#
|
||||
# \param path \type{string} The file to output to.
|
||||
# \param profiles \type{Profile} or \type{List} The profile(s) to write to the file.
|
||||
# \return \code True \endcode if the writing was successful, or \code
|
||||
# False \endcode if it wasn't.
|
||||
def write(self, path, profiles):
|
||||
"""Writes a profile to the specified file path.
|
||||
|
||||
The profile writer may write its own file format to the specified file.
|
||||
|
||||
:param path: :type{string} The file to output to.
|
||||
:param profiles: :type{Profile} or :type{List} The profile(s) to write to the file.
|
||||
:return: True if the writing was successful, or False if it wasn't.
|
||||
"""
|
||||
|
||||
raise NotImplementedError("Profile writer plugin was not correctly implemented. No write was specified.")
|
||||
|
@ -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__()
|
||||
|
||||
@ -36,8 +38,10 @@ class ConvexHullDecorator(SceneNodeDecorator):
|
||||
|
||||
# Make sure the timer is created on the main thread
|
||||
self._recompute_convex_hull_timer = None # type: Optional[QTimer]
|
||||
self._timer_scheduled_to_be_created = False
|
||||
from cura.CuraApplication import CuraApplication
|
||||
if CuraApplication.getInstance() is not None:
|
||||
self._timer_scheduled_to_be_created = True
|
||||
CuraApplication.getInstance().callLater(self.createRecomputeConvexHullTimer)
|
||||
|
||||
self._raft_thickness = 0.0
|
||||
@ -45,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()
|
||||
|
||||
@ -72,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
|
||||
|
||||
@ -88,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"):
|
||||
@ -106,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
|
||||
|
||||
@ -124,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"):
|
||||
@ -140,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
|
||||
|
||||
@ -155,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()
|
||||
@ -166,31 +187,29 @@ 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:
|
||||
self.recomputeConvexHull()
|
||||
from cura.CuraApplication import CuraApplication
|
||||
if not self._timer_scheduled_to_be_created:
|
||||
# The timer is not created and we never scheduled it. Time to create it now!
|
||||
CuraApplication.getInstance().callLater(self.createRecomputeConvexHullTimer)
|
||||
# Now we know for sure that the timer has been scheduled for creation, so we can try this again.
|
||||
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:
|
||||
@ -217,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:
|
||||
@ -244,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.
|
||||
@ -261,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.
|
||||
@ -288,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
|
||||
|
||||
@ -318,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
|
||||
@ -351,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")
|
||||
@ -373,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:
|
||||
@ -402,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")
|
||||
@ -423,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 \
|
||||
@ -443,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
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
|
||||
|
||||
|
||||
## A decorator that stores the amount an object has been moved below the platform.
|
||||
class ZOffsetDecorator(SceneNodeDecorator):
|
||||
"""A decorator that stores the amount an object has been moved below the platform."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._z_offset = 0.
|
||||
|
@ -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 os
|
||||
@ -33,12 +33,14 @@ if TYPE_CHECKING:
|
||||
catalog = i18nCatalog("cura")
|
||||
|
||||
|
||||
## Manager class that contains common actions to deal with containers in Cura.
|
||||
#
|
||||
# This is primarily intended as a class to be able to perform certain actions
|
||||
# from within QML. We want to be able to trigger things like removing a container
|
||||
# when a certain action happens. This can be done through this class.
|
||||
class ContainerManager(QObject):
|
||||
"""Manager class that contains common actions to deal with containers in Cura.
|
||||
|
||||
This is primarily intended as a class to be able to perform certain actions
|
||||
from within QML. We want to be able to trigger things like removing a container
|
||||
when a certain action happens. This can be done through this class.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, application: "CuraApplication") -> None:
|
||||
if ContainerManager.__instance is not None:
|
||||
@ -67,21 +69,23 @@ class ContainerManager(QObject):
|
||||
return ""
|
||||
return str(result)
|
||||
|
||||
## Set a metadata entry of the specified container.
|
||||
#
|
||||
# This will set the specified entry of the container's metadata to the specified
|
||||
# value. Note that entries containing dictionaries can have their entries changed
|
||||
# by using "/" as a separator. For example, to change an entry "foo" in a
|
||||
# dictionary entry "bar", you can specify "bar/foo" as entry name.
|
||||
#
|
||||
# \param container_node \type{ContainerNode}
|
||||
# \param entry_name \type{str} The name of the metadata entry to change.
|
||||
# \param entry_value The new value of the entry.
|
||||
#
|
||||
# TODO: This is ONLY used by MaterialView for material containers. Maybe refactor this.
|
||||
# Update: In order for QML to use objects and sub objects, those (sub) objects must all be QObject. Is that what we want?
|
||||
@pyqtSlot("QVariant", str, str)
|
||||
def setContainerMetaDataEntry(self, container_node: "ContainerNode", entry_name: str, entry_value: str) -> bool:
|
||||
"""Set a metadata entry of the specified container.
|
||||
|
||||
This will set the specified entry of the container's metadata to the specified
|
||||
value. Note that entries containing dictionaries can have their entries changed
|
||||
by using "/" as a separator. For example, to change an entry "foo" in a
|
||||
dictionary entry "bar", you can specify "bar/foo" as entry name.
|
||||
|
||||
:param container_node: :type{ContainerNode}
|
||||
:param entry_name: :type{str} The name of the metadata entry to change.
|
||||
:param entry_value: The new value of the entry.
|
||||
|
||||
TODO: This is ONLY used by MaterialView for material containers. Maybe refactor this.
|
||||
Update: In order for QML to use objects and sub objects, those (sub) objects must all be QObject. Is that what we want?
|
||||
"""
|
||||
|
||||
if container_node.container is None:
|
||||
Logger.log("w", "Container node {0} doesn't have a container.".format(container_node.container_id))
|
||||
return False
|
||||
@ -118,24 +122,28 @@ class ContainerManager(QObject):
|
||||
root_material.setMetaDataEntry(entry_name, entry_value)
|
||||
if sub_item_changed: #If it was only a sub-item that has changed then the setMetaDataEntry won't correctly notice that something changed, and we must manually signal that the metadata changed.
|
||||
root_material.metaDataChanged.emit(root_material)
|
||||
|
||||
cura.CuraApplication.CuraApplication.getInstance().getMachineManager().updateUponMaterialMetadataChange()
|
||||
return True
|
||||
|
||||
@pyqtSlot(str, result = str)
|
||||
def makeUniqueName(self, original_name: str) -> str:
|
||||
return cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry().uniqueName(original_name)
|
||||
|
||||
## Get a list of string that can be used as name filters for a Qt File Dialog
|
||||
#
|
||||
# This will go through the list of available container types and generate a list of strings
|
||||
# out of that. The strings are formatted as "description (*.extension)" and can be directly
|
||||
# passed to a nameFilters property of a Qt File Dialog.
|
||||
#
|
||||
# \param type_name Which types of containers to list. These types correspond to the "type"
|
||||
# key of the plugin metadata.
|
||||
#
|
||||
# \return A string list with name filters.
|
||||
@pyqtSlot(str, result = "QStringList")
|
||||
def getContainerNameFilters(self, type_name: str) -> List[str]:
|
||||
"""Get a list of string that can be used as name filters for a Qt File Dialog
|
||||
|
||||
This will go through the list of available container types and generate a list of strings
|
||||
out of that. The strings are formatted as "description (*.extension)" and can be directly
|
||||
passed to a nameFilters property of a Qt File Dialog.
|
||||
|
||||
:param type_name: Which types of containers to list. These types correspond to the "type"
|
||||
key of the plugin metadata.
|
||||
|
||||
:return: A string list with name filters.
|
||||
"""
|
||||
|
||||
if not self._container_name_filters:
|
||||
self._updateContainerNameFilters()
|
||||
|
||||
@ -147,17 +155,18 @@ class ContainerManager(QObject):
|
||||
filters.append("All Files (*)")
|
||||
return filters
|
||||
|
||||
## Export a container to a file
|
||||
#
|
||||
# \param container_id The ID of the container to export
|
||||
# \param file_type The type of file to save as. Should be in the form of "description (*.extension, *.ext)"
|
||||
# \param file_url_or_string The URL where to save the file.
|
||||
#
|
||||
# \return A dictionary containing a key "status" with a status code and a key "message" with a message
|
||||
# explaining the status.
|
||||
# The status code can be one of "error", "cancelled", "success"
|
||||
@pyqtSlot(str, str, QUrl, result = "QVariantMap")
|
||||
def exportContainer(self, container_id: str, file_type: str, file_url_or_string: Union[QUrl, str]) -> Dict[str, str]:
|
||||
"""Export a container to a file
|
||||
|
||||
:param container_id: The ID of the container to export
|
||||
:param file_type: The type of file to save as. Should be in the form of "description (*.extension, *.ext)"
|
||||
:param file_url_or_string: The URL where to save the file.
|
||||
|
||||
:return: A dictionary containing a key "status" with a status code and a key "message" with a message
|
||||
explaining the status. The status code can be one of "error", "cancelled", "success"
|
||||
"""
|
||||
|
||||
if not container_id or not file_type or not file_url_or_string:
|
||||
return {"status": "error", "message": "Invalid arguments"}
|
||||
|
||||
@ -206,19 +215,24 @@ class ContainerManager(QObject):
|
||||
if contents is None:
|
||||
return {"status": "error", "message": "Serialization returned None. Unable to write to file"}
|
||||
|
||||
with SaveFile(file_url, "w") as f:
|
||||
f.write(contents)
|
||||
try:
|
||||
with SaveFile(file_url, "w") as f:
|
||||
f.write(contents)
|
||||
except OSError:
|
||||
return {"status": "error", "message": "Unable to write to this location.", "path": file_url}
|
||||
|
||||
return {"status": "success", "message": "Successfully exported container", "path": file_url}
|
||||
|
||||
## Imports a profile from a file
|
||||
#
|
||||
# \param file_url A URL that points to the file to import.
|
||||
#
|
||||
# \return \type{Dict} dict with a 'status' key containing the string 'success' or 'error', and a 'message' key
|
||||
# containing a message for the user
|
||||
@pyqtSlot(QUrl, result = "QVariantMap")
|
||||
def importMaterialContainer(self, file_url_or_string: Union[QUrl, str]) -> Dict[str, str]:
|
||||
"""Imports a profile from a file
|
||||
|
||||
:param file_url: A URL that points to the file to import.
|
||||
|
||||
:return: :type{Dict} dict with a 'status' key containing the string 'success' or 'error', and a 'message' key
|
||||
containing a message for the user
|
||||
"""
|
||||
|
||||
if not file_url_or_string:
|
||||
return {"status": "error", "message": "Invalid path"}
|
||||
|
||||
@ -239,6 +253,8 @@ class ContainerManager(QObject):
|
||||
container_type = container_registry.getContainerForMimeType(mime_type)
|
||||
if not container_type:
|
||||
return {"status": "error", "message": "Could not find a container to handle the specified file."}
|
||||
if not issubclass(container_type, InstanceContainer):
|
||||
return {"status": "error", "message": "This is not a material container, but another type of file."}
|
||||
|
||||
container_id = urllib.parse.unquote_plus(mime_type.stripExtension(os.path.basename(file_url)))
|
||||
container_id = container_registry.uniqueName(container_id)
|
||||
@ -261,14 +277,16 @@ class ContainerManager(QObject):
|
||||
|
||||
return {"status": "success", "message": "Successfully imported container {0}".format(container.getName())}
|
||||
|
||||
## Update the current active quality changes container with the settings from the user container.
|
||||
#
|
||||
# This will go through the active global stack and all active extruder stacks and merge the changes from the user
|
||||
# container into the quality_changes container. After that, the user container is cleared.
|
||||
#
|
||||
# \return \type{bool} True if successful, False if not.
|
||||
@pyqtSlot(result = bool)
|
||||
def updateQualityChanges(self) -> bool:
|
||||
"""Update the current active quality changes container with the settings from the user container.
|
||||
|
||||
This will go through the active global stack and all active extruder stacks and merge the changes from the user
|
||||
container into the quality_changes container. After that, the user container is cleared.
|
||||
|
||||
:return: :type{bool} True if successful, False if not.
|
||||
"""
|
||||
|
||||
application = cura.CuraApplication.CuraApplication.getInstance()
|
||||
global_stack = application.getMachineManager().activeMachine
|
||||
if not global_stack:
|
||||
@ -278,7 +296,7 @@ class ContainerManager(QObject):
|
||||
|
||||
current_quality_changes_name = global_stack.qualityChanges.getName()
|
||||
current_quality_type = global_stack.quality.getMetaDataEntry("quality_type")
|
||||
extruder_stacks = list(global_stack.extruders.values())
|
||||
extruder_stacks = global_stack.extruderList
|
||||
container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry()
|
||||
machine_definition_id = ContainerTree.getInstance().machines[global_stack.definition.getId()].quality_definition
|
||||
for stack in [global_stack] + extruder_stacks:
|
||||
@ -308,9 +326,10 @@ class ContainerManager(QObject):
|
||||
|
||||
return True
|
||||
|
||||
## Clear the top-most (user) containers of the active stacks.
|
||||
@pyqtSlot()
|
||||
def clearUserContainers(self) -> None:
|
||||
"""Clear the top-most (user) containers of the active stacks."""
|
||||
|
||||
machine_manager = cura.CuraApplication.CuraApplication.getInstance().getMachineManager()
|
||||
machine_manager.blurSettings.emit()
|
||||
|
||||
@ -318,8 +337,7 @@ class ContainerManager(QObject):
|
||||
|
||||
# Go through global and extruder stacks and clear their topmost container (the user settings).
|
||||
global_stack = machine_manager.activeMachine
|
||||
extruder_stacks = list(global_stack.extruders.values())
|
||||
for stack in [global_stack] + extruder_stacks:
|
||||
for stack in [global_stack] + global_stack.extruderList:
|
||||
container = stack.userChanges
|
||||
container.clear()
|
||||
send_emits_containers.append(container)
|
||||
@ -327,28 +345,34 @@ class ContainerManager(QObject):
|
||||
# user changes are possibly added to make the current setup match the current enabled extruders
|
||||
machine_manager.correctExtruderSettings()
|
||||
|
||||
# The Print Sequence should be changed to match the current setup
|
||||
machine_manager.correctPrintSequence()
|
||||
|
||||
for container in send_emits_containers:
|
||||
container.sendPostponedEmits()
|
||||
|
||||
## Get a list of materials that have the same GUID as the reference material
|
||||
#
|
||||
# \param material_node The node representing the material for which to get
|
||||
# the same GUID.
|
||||
# \param exclude_self Whether to include the name of the material you
|
||||
# provided.
|
||||
# \return A list of names of materials with the same GUID.
|
||||
@pyqtSlot("QVariant", bool, result = "QStringList")
|
||||
def getLinkedMaterials(self, material_node: "MaterialNode", exclude_self: bool = False) -> List[str]:
|
||||
"""Get a list of materials that have the same GUID as the reference material
|
||||
|
||||
:param material_node: The node representing the material for which to get
|
||||
the same GUID.
|
||||
:param exclude_self: Whether to include the name of the material you provided.
|
||||
:return: A list of names of materials with the same GUID.
|
||||
"""
|
||||
|
||||
same_guid = ContainerRegistry.getInstance().findInstanceContainersMetadata(GUID = material_node.guid)
|
||||
if exclude_self:
|
||||
return list({meta["name"] for meta in same_guid if meta["base_file"] != material_node.base_file})
|
||||
else:
|
||||
return list({meta["name"] for meta in same_guid})
|
||||
|
||||
## Unlink a material from all other materials by creating a new GUID
|
||||
# \param material_id \type{str} the id of the material to create a new GUID for.
|
||||
@pyqtSlot("QVariant")
|
||||
def unlinkMaterial(self, material_node: "MaterialNode") -> None:
|
||||
"""Unlink a material from all other materials by creating a new GUID
|
||||
|
||||
:param material_id: :type{str} the id of the material to create a new GUID for.
|
||||
"""
|
||||
# Get the material group
|
||||
if material_node.container is None: # Failed to lazy-load this container.
|
||||
return
|
||||
@ -423,9 +447,10 @@ class ContainerManager(QObject):
|
||||
name_filter = "{0} ({1})".format(mime_type.comment, suffix_list)
|
||||
self._container_name_filters[name_filter] = entry
|
||||
|
||||
## Import single profile, file_url does not have to end with curaprofile
|
||||
@pyqtSlot(QUrl, result = "QVariantMap")
|
||||
def importProfile(self, file_url: QUrl) -> Dict[str, str]:
|
||||
"""Import single profile, file_url does not have to end with curaprofile"""
|
||||
|
||||
if not file_url.isValid():
|
||||
return {"status": "error", "message": catalog.i18nc("@info:status", "Invalid file URL:") + " " + str(file_url)}
|
||||
path = file_url.toLocalFile()
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from typing import Any, cast, List, Optional
|
||||
from typing import Any, cast, List, Optional, Dict
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject
|
||||
|
||||
from UM.Application import Application
|
||||
@ -18,25 +18,27 @@ from cura.Settings import cura_empty_instance_containers
|
||||
from . import Exceptions
|
||||
|
||||
|
||||
## Base class for Cura related stacks that want to enforce certain containers are available.
|
||||
#
|
||||
# This class makes sure that the stack has the following containers set: user changes, quality
|
||||
# changes, quality, material, variant, definition changes and finally definition. Initially,
|
||||
# these will be equal to the empty instance container.
|
||||
#
|
||||
# The container types are determined based on the following criteria:
|
||||
# - user: An InstanceContainer with the metadata entry "type" set to "user".
|
||||
# - quality changes: An InstanceContainer with the metadata entry "type" set to "quality_changes".
|
||||
# - quality: An InstanceContainer with the metadata entry "type" set to "quality".
|
||||
# - material: An InstanceContainer with the metadata entry "type" set to "material".
|
||||
# - variant: An InstanceContainer with the metadata entry "type" set to "variant".
|
||||
# - definition changes: An InstanceContainer with the metadata entry "type" set to "definition_changes".
|
||||
# - definition: A DefinitionContainer.
|
||||
#
|
||||
# Internally, this class ensures the mentioned containers are always there and kept in a specific order.
|
||||
# This also means that operations on the stack that modifies the container ordering is prohibited and
|
||||
# will raise an exception.
|
||||
class CuraContainerStack(ContainerStack):
|
||||
"""Base class for Cura related stacks that want to enforce certain containers are available.
|
||||
|
||||
This class makes sure that the stack has the following containers set: user changes, quality
|
||||
changes, quality, material, variant, definition changes and finally definition. Initially,
|
||||
these will be equal to the empty instance container.
|
||||
|
||||
The container types are determined based on the following criteria:
|
||||
- user: An InstanceContainer with the metadata entry "type" set to "user".
|
||||
- quality changes: An InstanceContainer with the metadata entry "type" set to "quality_changes".
|
||||
- quality: An InstanceContainer with the metadata entry "type" set to "quality".
|
||||
- material: An InstanceContainer with the metadata entry "type" set to "material".
|
||||
- variant: An InstanceContainer with the metadata entry "type" set to "variant".
|
||||
- definition changes: An InstanceContainer with the metadata entry "type" set to "definition_changes".
|
||||
- definition: A DefinitionContainer.
|
||||
|
||||
Internally, this class ensures the mentioned containers are always there and kept in a specific order.
|
||||
This also means that operations on the stack that modifies the container ordering is prohibited and
|
||||
will raise an exception.
|
||||
"""
|
||||
|
||||
def __init__(self, container_id: str) -> None:
|
||||
super().__init__(container_id)
|
||||
|
||||
@ -58,104 +60,138 @@ class CuraContainerStack(ContainerStack):
|
||||
import cura.CuraApplication #Here to prevent circular imports.
|
||||
self.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.SettingVersion)
|
||||
|
||||
self._settable_per_extruder_cache = {} # type: Dict[str, Any]
|
||||
|
||||
self.setDirty(False)
|
||||
|
||||
# This is emitted whenever the containersChanged signal from the ContainerStack base class is emitted.
|
||||
pyqtContainersChanged = pyqtSignal()
|
||||
|
||||
## Set the user changes container.
|
||||
#
|
||||
# \param new_user_changes The new user changes container. It is expected to have a "type" metadata entry with the value "user".
|
||||
def setUserChanges(self, new_user_changes: InstanceContainer) -> None:
|
||||
"""Set the user changes container.
|
||||
|
||||
:param new_user_changes: The new user changes container. It is expected to have a "type" metadata entry with the value "user".
|
||||
"""
|
||||
|
||||
self.replaceContainer(_ContainerIndexes.UserChanges, new_user_changes)
|
||||
|
||||
## Get the user changes container.
|
||||
#
|
||||
# \return The user changes container. Should always be a valid container, but can be equal to the empty InstanceContainer.
|
||||
@pyqtProperty(InstanceContainer, fset = setUserChanges, notify = pyqtContainersChanged)
|
||||
def userChanges(self) -> InstanceContainer:
|
||||
"""Get the user changes container.
|
||||
|
||||
:return: The user changes container. Should always be a valid container, but can be equal to the empty InstanceContainer.
|
||||
"""
|
||||
|
||||
return cast(InstanceContainer, self._containers[_ContainerIndexes.UserChanges])
|
||||
|
||||
## Set the quality changes container.
|
||||
#
|
||||
# \param new_quality_changes The new quality changes container. It is expected to have a "type" metadata entry with the value "quality_changes".
|
||||
def setQualityChanges(self, new_quality_changes: InstanceContainer, postpone_emit = False) -> None:
|
||||
"""Set the quality changes container.
|
||||
|
||||
:param new_quality_changes: The new quality changes container. It is expected to have a "type" metadata entry with the value "quality_changes".
|
||||
"""
|
||||
|
||||
self.replaceContainer(_ContainerIndexes.QualityChanges, new_quality_changes, postpone_emit = postpone_emit)
|
||||
|
||||
## Get the quality changes container.
|
||||
#
|
||||
# \return The quality changes container. Should always be a valid container, but can be equal to the empty InstanceContainer.
|
||||
@pyqtProperty(InstanceContainer, fset = setQualityChanges, notify = pyqtContainersChanged)
|
||||
def qualityChanges(self) -> InstanceContainer:
|
||||
"""Get the quality changes container.
|
||||
|
||||
:return: The quality changes container. Should always be a valid container, but can be equal to the empty InstanceContainer.
|
||||
"""
|
||||
|
||||
return cast(InstanceContainer, self._containers[_ContainerIndexes.QualityChanges])
|
||||
|
||||
## Set the intent container.
|
||||
#
|
||||
# \param new_intent The new intent container. It is expected to have a "type" metadata entry with the value "intent".
|
||||
def setIntent(self, new_intent: InstanceContainer, postpone_emit: bool = False) -> None:
|
||||
"""Set the intent container.
|
||||
|
||||
:param new_intent: The new intent container. It is expected to have a "type" metadata entry with the value "intent".
|
||||
"""
|
||||
|
||||
self.replaceContainer(_ContainerIndexes.Intent, new_intent, postpone_emit = postpone_emit)
|
||||
|
||||
## Get the quality container.
|
||||
#
|
||||
# \return The intent container. Should always be a valid container, but can be equal to the empty InstanceContainer.
|
||||
@pyqtProperty(InstanceContainer, fset = setIntent, notify = pyqtContainersChanged)
|
||||
def intent(self) -> InstanceContainer:
|
||||
"""Get the quality container.
|
||||
|
||||
:return: The intent container. Should always be a valid container, but can be equal to the empty InstanceContainer.
|
||||
"""
|
||||
|
||||
return cast(InstanceContainer, self._containers[_ContainerIndexes.Intent])
|
||||
|
||||
## Set the quality container.
|
||||
#
|
||||
# \param new_quality The new quality container. It is expected to have a "type" metadata entry with the value "quality".
|
||||
def setQuality(self, new_quality: InstanceContainer, postpone_emit: bool = False) -> None:
|
||||
"""Set the quality container.
|
||||
|
||||
:param new_quality: The new quality container. It is expected to have a "type" metadata entry with the value "quality".
|
||||
"""
|
||||
|
||||
self.replaceContainer(_ContainerIndexes.Quality, new_quality, postpone_emit = postpone_emit)
|
||||
|
||||
## Get the quality container.
|
||||
#
|
||||
# \return The quality container. Should always be a valid container, but can be equal to the empty InstanceContainer.
|
||||
@pyqtProperty(InstanceContainer, fset = setQuality, notify = pyqtContainersChanged)
|
||||
def quality(self) -> InstanceContainer:
|
||||
"""Get the quality container.
|
||||
|
||||
:return: The quality container. Should always be a valid container, but can be equal to the empty InstanceContainer.
|
||||
"""
|
||||
|
||||
return cast(InstanceContainer, self._containers[_ContainerIndexes.Quality])
|
||||
|
||||
## Set the material container.
|
||||
#
|
||||
# \param new_material The new material container. It is expected to have a "type" metadata entry with the value "material".
|
||||
def setMaterial(self, new_material: InstanceContainer, postpone_emit: bool = False) -> None:
|
||||
"""Set the material container.
|
||||
|
||||
:param new_material: The new material container. It is expected to have a "type" metadata entry with the value "material".
|
||||
"""
|
||||
|
||||
self.replaceContainer(_ContainerIndexes.Material, new_material, postpone_emit = postpone_emit)
|
||||
|
||||
## Get the material container.
|
||||
#
|
||||
# \return The material container. Should always be a valid container, but can be equal to the empty InstanceContainer.
|
||||
@pyqtProperty(InstanceContainer, fset = setMaterial, notify = pyqtContainersChanged)
|
||||
def material(self) -> InstanceContainer:
|
||||
"""Get the material container.
|
||||
|
||||
:return: The material container. Should always be a valid container, but can be equal to the empty InstanceContainer.
|
||||
"""
|
||||
|
||||
return cast(InstanceContainer, self._containers[_ContainerIndexes.Material])
|
||||
|
||||
## Set the variant container.
|
||||
#
|
||||
# \param new_variant The new variant container. It is expected to have a "type" metadata entry with the value "variant".
|
||||
def setVariant(self, new_variant: InstanceContainer) -> None:
|
||||
"""Set the variant container.
|
||||
|
||||
:param new_variant: The new variant container. It is expected to have a "type" metadata entry with the value "variant".
|
||||
"""
|
||||
|
||||
self.replaceContainer(_ContainerIndexes.Variant, new_variant)
|
||||
|
||||
## Get the variant container.
|
||||
#
|
||||
# \return The variant container. Should always be a valid container, but can be equal to the empty InstanceContainer.
|
||||
@pyqtProperty(InstanceContainer, fset = setVariant, notify = pyqtContainersChanged)
|
||||
def variant(self) -> InstanceContainer:
|
||||
"""Get the variant container.
|
||||
|
||||
:return: The variant container. Should always be a valid container, but can be equal to the empty InstanceContainer.
|
||||
"""
|
||||
|
||||
return cast(InstanceContainer, self._containers[_ContainerIndexes.Variant])
|
||||
|
||||
## Set the definition changes container.
|
||||
#
|
||||
# \param new_definition_changes The new definition changes container. It is expected to have a "type" metadata entry with the value "definition_changes".
|
||||
def setDefinitionChanges(self, new_definition_changes: InstanceContainer) -> None:
|
||||
"""Set the definition changes container.
|
||||
|
||||
:param new_definition_changes: The new definition changes container. It is expected to have a "type" metadata entry with the value "definition_changes".
|
||||
"""
|
||||
|
||||
self.replaceContainer(_ContainerIndexes.DefinitionChanges, new_definition_changes)
|
||||
|
||||
## Get the definition changes container.
|
||||
#
|
||||
# \return The definition changes container. Should always be a valid container, but can be equal to the empty InstanceContainer.
|
||||
@pyqtProperty(InstanceContainer, fset = setDefinitionChanges, notify = pyqtContainersChanged)
|
||||
def definitionChanges(self) -> InstanceContainer:
|
||||
"""Get the definition changes container.
|
||||
|
||||
:return: The definition changes container. Should always be a valid container, but can be equal to the empty InstanceContainer.
|
||||
"""
|
||||
|
||||
return cast(InstanceContainer, self._containers[_ContainerIndexes.DefinitionChanges])
|
||||
|
||||
## Set the definition container.
|
||||
#
|
||||
# \param new_definition The new definition container. It is expected to have a "type" metadata entry with the value "definition".
|
||||
def setDefinition(self, new_definition: DefinitionContainerInterface) -> None:
|
||||
"""Set the definition container.
|
||||
|
||||
:param new_definition: The new definition container. It is expected to have a "type" metadata entry with the value "definition".
|
||||
"""
|
||||
|
||||
self.replaceContainer(_ContainerIndexes.Definition, new_definition)
|
||||
|
||||
def getDefinition(self) -> "DefinitionContainer":
|
||||
@ -171,14 +207,16 @@ class CuraContainerStack(ContainerStack):
|
||||
def getTop(self) -> "InstanceContainer":
|
||||
return self.userChanges
|
||||
|
||||
## Check whether the specified setting has a 'user' value.
|
||||
#
|
||||
# A user value here is defined as the setting having a value in either
|
||||
# the UserChanges or QualityChanges container.
|
||||
#
|
||||
# \return True if the setting has a user value, False if not.
|
||||
@pyqtSlot(str, result = bool)
|
||||
def hasUserValue(self, key: str) -> bool:
|
||||
"""Check whether the specified setting has a 'user' value.
|
||||
|
||||
A user value here is defined as the setting having a value in either
|
||||
the UserChanges or QualityChanges container.
|
||||
|
||||
:return: True if the setting has a user value, False if not.
|
||||
"""
|
||||
|
||||
if self._containers[_ContainerIndexes.UserChanges].hasProperty(key, "value"):
|
||||
return True
|
||||
|
||||
@ -187,51 +225,61 @@ class CuraContainerStack(ContainerStack):
|
||||
|
||||
return False
|
||||
|
||||
## Set a property of a setting.
|
||||
#
|
||||
# This will set a property of a specified setting. Since the container stack does not contain
|
||||
# any settings itself, it is required to specify a container to set the property on. The target
|
||||
# container is matched by container type.
|
||||
#
|
||||
# \param key The key of the setting to set.
|
||||
# \param property_name The name of the property to set.
|
||||
# \param new_value The new value to set the property to.
|
||||
def setProperty(self, key: str, property_name: str, property_value: Any, container: "ContainerInterface" = None, set_from_cache: bool = False) -> None:
|
||||
"""Set a property of a setting.
|
||||
|
||||
This will set a property of a specified setting. Since the container stack does not contain
|
||||
any settings itself, it is required to specify a container to set the property on. The target
|
||||
container is matched by container type.
|
||||
|
||||
:param key: The key of the setting to set.
|
||||
:param property_name: The name of the property to set.
|
||||
:param new_value: The new value to set the property to.
|
||||
"""
|
||||
|
||||
container_index = _ContainerIndexes.UserChanges
|
||||
self._containers[container_index].setProperty(key, property_name, property_value, container, set_from_cache)
|
||||
|
||||
## Overridden from ContainerStack
|
||||
#
|
||||
# Since we have a fixed order of containers in the stack and this method would modify the container
|
||||
# ordering, we disallow this operation.
|
||||
@override(ContainerStack)
|
||||
def addContainer(self, container: ContainerInterface) -> None:
|
||||
"""Overridden from ContainerStack
|
||||
|
||||
Since we have a fixed order of containers in the stack and this method would modify the container
|
||||
ordering, we disallow this operation.
|
||||
"""
|
||||
|
||||
raise Exceptions.InvalidOperationError("Cannot add a container to Global stack")
|
||||
|
||||
## Overridden from ContainerStack
|
||||
#
|
||||
# Since we have a fixed order of containers in the stack and this method would modify the container
|
||||
# ordering, we disallow this operation.
|
||||
@override(ContainerStack)
|
||||
def insertContainer(self, index: int, container: ContainerInterface) -> None:
|
||||
"""Overridden from ContainerStack
|
||||
|
||||
Since we have a fixed order of containers in the stack and this method would modify the container
|
||||
ordering, we disallow this operation.
|
||||
"""
|
||||
|
||||
raise Exceptions.InvalidOperationError("Cannot insert a container into Global stack")
|
||||
|
||||
## Overridden from ContainerStack
|
||||
#
|
||||
# Since we have a fixed order of containers in the stack and this method would modify the container
|
||||
# ordering, we disallow this operation.
|
||||
@override(ContainerStack)
|
||||
def removeContainer(self, index: int = 0) -> None:
|
||||
"""Overridden from ContainerStack
|
||||
|
||||
Since we have a fixed order of containers in the stack and this method would modify the container
|
||||
ordering, we disallow this operation.
|
||||
"""
|
||||
|
||||
raise Exceptions.InvalidOperationError("Cannot remove a container from Global stack")
|
||||
|
||||
## Overridden from ContainerStack
|
||||
#
|
||||
# Replaces the container at the specified index with another container.
|
||||
# This version performs checks to make sure the new container has the expected metadata and type.
|
||||
#
|
||||
# \throws Exception.InvalidContainerError Raised when trying to replace a container with a container that has an incorrect type.
|
||||
@override(ContainerStack)
|
||||
def replaceContainer(self, index: int, container: ContainerInterface, postpone_emit: bool = False) -> None:
|
||||
"""Overridden from ContainerStack
|
||||
|
||||
Replaces the container at the specified index with another container.
|
||||
This version performs checks to make sure the new container has the expected metadata and type.
|
||||
|
||||
:throws Exception.InvalidContainerError Raised when trying to replace a container with a container that has an incorrect type.
|
||||
"""
|
||||
|
||||
expected_type = _ContainerIndexes.IndexTypeMap[index]
|
||||
if expected_type == "definition":
|
||||
if not isinstance(container, DefinitionContainer):
|
||||
@ -245,16 +293,18 @@ class CuraContainerStack(ContainerStack):
|
||||
|
||||
super().replaceContainer(index, container, postpone_emit)
|
||||
|
||||
## Overridden from ContainerStack
|
||||
#
|
||||
# This deserialize will make sure the internal list of containers matches with what we expect.
|
||||
# It will first check to see if the container at a certain index already matches with what we
|
||||
# expect. If it does not, it will search for a matching container with the correct type. Should
|
||||
# no container with the correct type be found, it will use the empty container.
|
||||
#
|
||||
# \throws InvalidContainerStackError Raised when no definition can be found for the stack.
|
||||
@override(ContainerStack)
|
||||
def deserialize(self, serialized: str, file_name: Optional[str] = None) -> str:
|
||||
"""Overridden from ContainerStack
|
||||
|
||||
This deserialize will make sure the internal list of containers matches with what we expect.
|
||||
It will first check to see if the container at a certain index already matches with what we
|
||||
expect. If it does not, it will search for a matching container with the correct type. Should
|
||||
no container with the correct type be found, it will use the empty container.
|
||||
|
||||
:raise InvalidContainerStackError: Raised when no definition can be found for the stack.
|
||||
"""
|
||||
|
||||
# update the serialized data first
|
||||
serialized = super().deserialize(serialized, file_name)
|
||||
|
||||
@ -298,10 +348,9 @@ class CuraContainerStack(ContainerStack):
|
||||
## TODO; Deserialize the containers.
|
||||
return serialized
|
||||
|
||||
## protected:
|
||||
|
||||
# Helper to make sure we emit a PyQt signal on container changes.
|
||||
def _onContainersChanged(self, container: Any) -> None:
|
||||
"""Helper to make sure we emit a PyQt signal on container changes."""
|
||||
|
||||
Application.getInstance().callLater(self.pyqtContainersChanged.emit)
|
||||
|
||||
# Helper that can be overridden to get the "machine" definition, that is, the definition that defines the machine
|
||||
@ -309,16 +358,18 @@ class CuraContainerStack(ContainerStack):
|
||||
def _getMachineDefinition(self) -> DefinitionContainer:
|
||||
return self.definition
|
||||
|
||||
## Find the ID that should be used when searching for instance containers for a specified definition.
|
||||
#
|
||||
# This handles the situation where the definition specifies we should use a different definition when
|
||||
# searching for instance containers.
|
||||
#
|
||||
# \param machine_definition The definition to find the "quality definition" for.
|
||||
#
|
||||
# \return The ID of the definition container to use when searching for instance containers.
|
||||
@classmethod
|
||||
def _findInstanceContainerDefinitionId(cls, machine_definition: DefinitionContainerInterface) -> str:
|
||||
"""Find the ID that should be used when searching for instance containers for a specified definition.
|
||||
|
||||
This handles the situation where the definition specifies we should use a different definition when
|
||||
searching for instance containers.
|
||||
|
||||
:param machine_definition: The definition to find the "quality definition" for.
|
||||
|
||||
:return: The ID of the definition container to use when searching for instance containers.
|
||||
"""
|
||||
|
||||
quality_definition = machine_definition.getMetaDataEntry("quality_definition")
|
||||
if not quality_definition:
|
||||
return machine_definition.id #type: ignore
|
||||
@ -330,17 +381,30 @@ class CuraContainerStack(ContainerStack):
|
||||
|
||||
return cls._findInstanceContainerDefinitionId(definitions[0])
|
||||
|
||||
## getProperty for extruder positions, with translation from -1 to default extruder number
|
||||
def getExtruderPositionValueWithDefault(self, key):
|
||||
"""getProperty for extruder positions, with translation from -1 to default extruder number"""
|
||||
|
||||
value = self.getProperty(key, "value")
|
||||
if value == -1:
|
||||
value = int(Application.getInstance().getMachineManager().defaultExtruderPosition)
|
||||
return value
|
||||
|
||||
## private:
|
||||
def getProperty(self, key: str, property_name: str, context = None) -> Any:
|
||||
if property_name == "settable_per_extruder":
|
||||
# Setable per extruder isn't a value that can ever change. So once we requested it once, we can just keep
|
||||
# that in memory.
|
||||
try:
|
||||
return self._settable_per_extruder_cache[key]
|
||||
except KeyError:
|
||||
self._settable_per_extruder_cache[key] = super().getProperty(key, property_name, context)
|
||||
return self._settable_per_extruder_cache[key]
|
||||
|
||||
return super().getProperty(key, property_name, context)
|
||||
|
||||
|
||||
# Private helper class to keep track of container positions and their types.
|
||||
class _ContainerIndexes:
|
||||
"""Private helper class to keep track of container positions and their types."""
|
||||
|
||||
UserChanges = 0
|
||||
QualityChanges = 1
|
||||
Intent = 2
|
||||
|
@ -9,22 +9,24 @@ from UM.Settings.Interfaces import DefinitionContainerInterface
|
||||
from UM.Settings.InstanceContainer import InstanceContainer
|
||||
|
||||
from cura.Machines.ContainerTree import ContainerTree
|
||||
from cura.Machines.MachineNode import MachineNode
|
||||
from .GlobalStack import GlobalStack
|
||||
from .ExtruderStack import ExtruderStack
|
||||
|
||||
|
||||
## Contains helper functions to create new machines.
|
||||
class CuraStackBuilder:
|
||||
"""Contains helper functions to create new machines."""
|
||||
|
||||
## Create a new instance of a machine.
|
||||
#
|
||||
# \param name The name of the new machine.
|
||||
# \param definition_id The ID of the machine definition to use.
|
||||
#
|
||||
# \return The new global stack or None if an error occurred.
|
||||
@classmethod
|
||||
def createMachine(cls, name: str, definition_id: str) -> Optional[GlobalStack]:
|
||||
def createMachine(cls, name: str, definition_id: str, machine_extruder_count: Optional[int] = None) -> Optional[GlobalStack]:
|
||||
"""Create a new instance of a machine.
|
||||
|
||||
:param name: The name of the new machine.
|
||||
:param definition_id: The ID of the machine definition to use.
|
||||
:param machine_extruder_count: The number of extruders in the machine.
|
||||
|
||||
:return: The new global stack or None if an error occurred.
|
||||
"""
|
||||
|
||||
from cura.CuraApplication import CuraApplication
|
||||
application = CuraApplication.getInstance()
|
||||
registry = application.getContainerRegistry()
|
||||
@ -58,9 +60,20 @@ class CuraStackBuilder:
|
||||
# Create ExtruderStacks
|
||||
extruder_dict = machine_definition.getMetaDataEntry("machine_extruder_trains")
|
||||
for position in extruder_dict:
|
||||
cls.createExtruderStackWithDefaultSetup(new_global_stack, position)
|
||||
try:
|
||||
cls.createExtruderStackWithDefaultSetup(new_global_stack, position)
|
||||
except IndexError as e:
|
||||
Logger.logException("e", "Failed to create an extruder stack for position {pos}: {err}".format(pos = position, err = str(e)))
|
||||
return None
|
||||
|
||||
for new_extruder in new_global_stack.extruders.values(): # Only register the extruders if we're sure that all of them are correct.
|
||||
# If given, set the machine_extruder_count when creating the machine, or else the extruderList used bellow will
|
||||
# not return the correct extruder list (since by default, the machine_extruder_count is 1) in machines with
|
||||
# settable number of extruders.
|
||||
if machine_extruder_count and 0 <= machine_extruder_count <= len(extruder_dict):
|
||||
new_global_stack.setProperty("machine_extruder_count", "value", machine_extruder_count)
|
||||
|
||||
# Only register the extruders if we're sure that all of them are correct.
|
||||
for new_extruder in new_global_stack.extruderList:
|
||||
registry.addContainer(new_extruder)
|
||||
|
||||
# Register the global stack after the extruder stacks are created. This prevents the registry from adding another
|
||||
@ -69,12 +82,14 @@ class CuraStackBuilder:
|
||||
|
||||
return new_global_stack
|
||||
|
||||
## Create a default Extruder Stack
|
||||
#
|
||||
# \param global_stack The global stack this extruder refers to.
|
||||
# \param extruder_position The position of the current extruder.
|
||||
@classmethod
|
||||
def createExtruderStackWithDefaultSetup(cls, global_stack: "GlobalStack", extruder_position: int) -> None:
|
||||
"""Create a default Extruder Stack
|
||||
|
||||
:param global_stack: The global stack this extruder refers to.
|
||||
:param extruder_position: The position of the current extruder.
|
||||
"""
|
||||
|
||||
from cura.CuraApplication import CuraApplication
|
||||
application = CuraApplication.getInstance()
|
||||
registry = application.getContainerRegistry()
|
||||
@ -118,17 +133,6 @@ class CuraStackBuilder:
|
||||
|
||||
registry.addContainer(new_extruder)
|
||||
|
||||
## Create a new Extruder stack
|
||||
#
|
||||
# \param new_stack_id The ID of the new stack.
|
||||
# \param extruder_definition The definition to base the new stack on.
|
||||
# \param machine_definition_id The ID of the machine definition to use for the user container.
|
||||
# \param position The position the extruder occupies in the machine.
|
||||
# \param variant_container The variant selected for the current extruder.
|
||||
# \param material_container The material selected for the current extruder.
|
||||
# \param quality_container The quality selected for the current extruder.
|
||||
#
|
||||
# \return A new Extruder stack instance with the specified parameters.
|
||||
@classmethod
|
||||
def createExtruderStack(cls, new_stack_id: str, extruder_definition: DefinitionContainerInterface,
|
||||
machine_definition_id: str,
|
||||
@ -137,6 +141,19 @@ class CuraStackBuilder:
|
||||
material_container: "InstanceContainer",
|
||||
quality_container: "InstanceContainer") -> ExtruderStack:
|
||||
|
||||
"""Create a new Extruder stack
|
||||
|
||||
:param new_stack_id: The ID of the new stack.
|
||||
:param extruder_definition: The definition to base the new stack on.
|
||||
:param machine_definition_id: The ID of the machine definition to use for the user container.
|
||||
:param position: The position the extruder occupies in the machine.
|
||||
:param variant_container: The variant selected for the current extruder.
|
||||
:param material_container: The material selected for the current extruder.
|
||||
:param quality_container: The quality selected for the current extruder.
|
||||
|
||||
:return: A new Extruder stack instance with the specified parameters.
|
||||
"""
|
||||
|
||||
from cura.CuraApplication import CuraApplication
|
||||
application = CuraApplication.getInstance()
|
||||
registry = application.getContainerRegistry()
|
||||
@ -165,29 +182,23 @@ class CuraStackBuilder:
|
||||
|
||||
return stack
|
||||
|
||||
## Create a new Global stack
|
||||
#
|
||||
# \param new_stack_id The ID of the new stack.
|
||||
# \param definition The definition to base the new stack on.
|
||||
# \param kwargs You can add keyword arguments to specify IDs of containers to use for a specific type, for example "variant": "0.4mm"
|
||||
#
|
||||
# \return A new Global stack instance with the specified parameters.
|
||||
|
||||
## Create a new Global stack
|
||||
#
|
||||
# \param new_stack_id The ID of the new stack.
|
||||
# \param definition The definition to base the new stack on.
|
||||
# \param variant_container The variant selected for the current stack.
|
||||
# \param material_container The material selected for the current stack.
|
||||
# \param quality_container The quality selected for the current stack.
|
||||
#
|
||||
# \return A new Global stack instance with the specified parameters.
|
||||
@classmethod
|
||||
def createGlobalStack(cls, new_stack_id: str, definition: DefinitionContainerInterface,
|
||||
variant_container: "InstanceContainer",
|
||||
material_container: "InstanceContainer",
|
||||
quality_container: "InstanceContainer") -> GlobalStack:
|
||||
|
||||
"""Create a new Global stack
|
||||
|
||||
:param new_stack_id: The ID of the new stack.
|
||||
:param definition: The definition to base the new stack on.
|
||||
:param variant_container: The variant selected for the current stack.
|
||||
:param material_container: The material selected for the current stack.
|
||||
:param quality_container: The quality selected for the current stack.
|
||||
|
||||
:return: A new Global stack instance with the specified parameters.
|
||||
"""
|
||||
|
||||
from cura.CuraApplication import CuraApplication
|
||||
application = CuraApplication.getInstance()
|
||||
registry = application.getContainerRegistry()
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user