Merge branch 'master' into mb-small-features-slowdown

# Conflicts:
#	resources/definitions/fdmprinter.def.json
This commit is contained in:
Mark Burton 2019-04-07 08:46:44 +01:00
commit 1dde8622ea
1934 changed files with 498374 additions and 183559 deletions

2
.gitignore vendored
View file

@ -15,6 +15,7 @@ LC_MESSAGES
.cache .cache
*.qmlc *.qmlc
.mypy_cache .mypy_cache
.pytest_cache
#MacOS #MacOS
.DS_Store .DS_Store
@ -25,6 +26,7 @@ LC_MESSAGES
*.lprof *.lprof
*~ *~
*.qm *.qm
.directory
.idea .idea
cura.desktop cura.desktop

View file

@ -17,8 +17,14 @@ if(CURA_DEBUGMODE)
set(_cura_debugmode "ON") set(_cura_debugmode "ON")
endif() endif()
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") set(CURA_VERSION "master" CACHE STRING "Version name of Cura")
set(CURA_BUILDTYPE "" CACHE STRING "Build type of Cura, eg. 'PPA'") set(CURA_BUILDTYPE "" CACHE STRING "Build type of Cura, eg. 'PPA'")
set(CURA_SDK_VERSION "" CACHE STRING "SDK version of Cura")
set(CURA_CLOUD_API_ROOT "" CACHE STRING "Alternative Cura cloud API root")
set(CURA_CLOUD_API_VERSION "" CACHE STRING "Alternative Cura cloud API version")
configure_file(${CMAKE_SOURCE_DIR}/cura.desktop.in ${CMAKE_BINARY_DIR}/cura.desktop @ONLY) configure_file(${CMAKE_SOURCE_DIR}/cura.desktop.in ${CMAKE_BINARY_DIR}/cura.desktop @ONLY)
configure_file(cura/CuraVersion.py.in CuraVersion.py @ONLY) configure_file(cura/CuraVersion.py.in CuraVersion.py @ONLY)

49
Jenkinsfile vendored
View file

@ -1,7 +1,11 @@
parallel_nodes(['linux && cura', 'windows && cura']) { parallel_nodes(['linux && cura', 'windows && cura'])
timeout(time: 2, unit: "HOURS") { {
timeout(time: 2, unit: "HOURS")
{
// Prepare building // Prepare building
stage('Prepare') { stage('Prepare')
{
// Ensure we start with a clean build directory. // Ensure we start with a clean build directory.
step([$class: 'WsCleanup']) step([$class: 'WsCleanup'])
@ -10,13 +14,17 @@ parallel_nodes(['linux && cura', 'windows && cura']) {
} }
// If any error occurs during building, we want to catch it and continue with the "finale" stage. // If any error occurs during building, we want to catch it and continue with the "finale" stage.
catchError { catchError
{
// Building and testing should happen in a subdirectory. // Building and testing should happen in a subdirectory.
dir('build') { dir('build')
{
// Perform the "build". Since Uranium is Python code, this basically only ensures CMake is setup. // Perform the "build". Since Uranium is Python code, this basically only ensures CMake is setup.
stage('Build') { stage('Build')
{
def branch = env.BRANCH_NAME def branch = env.BRANCH_NAME
if(!fileExists("${env.CURA_ENVIRONMENT_PATH}/${branch}")) { if(!fileExists("${env.CURA_ENVIRONMENT_PATH}/${branch}"))
{
branch = "master" branch = "master"
} }
@ -26,18 +34,37 @@ parallel_nodes(['linux && cura', 'windows && cura']) {
} }
// Try and run the unit tests. If this stage fails, we consider the build to be "unstable". // Try and run the unit tests. If this stage fails, we consider the build to be "unstable".
stage('Unit Test') { stage('Unit Test')
{
if (isUnix())
{
// For Linux
try { try {
make('test') sh 'make CTEST_OUTPUT_ON_FAILURE=TRUE test'
} catch(e) { } catch(e)
{
currentBuild.result = "UNSTABLE" currentBuild.result = "UNSTABLE"
} }
} }
else
{
// For Windows
try
{
// This also does code style checks.
bat 'ctest -V'
} catch(e)
{
currentBuild.result = "UNSTABLE"
}
}
}
} }
} }
// Perform any post-build actions like notification and publishing of unit tests. // Perform any post-build actions like notification and publishing of unit tests.
stage('Finalize') { stage('Finalize')
{
// Publish the test results to Jenkins. // Publish the test results to Jenkins.
junit allowEmptyResults: true, testResults: 'build/junit*.xml' junit allowEmptyResults: true, testResults: 'build/junit*.xml'

View file

@ -20,8 +20,9 @@ Dependencies
------------ ------------
* [Uranium](https://github.com/Ultimaker/Uranium) Cura is built on top of the Uranium framework. * [Uranium](https://github.com/Ultimaker/Uranium) Cura is built on top of the Uranium framework.
* [CuraEngine](https://github.com/Ultimaker/CuraEngine) This will be needed at runtime to perform the actual slicing. * [CuraEngine](https://github.com/Ultimaker/CuraEngine) This will be needed at runtime to perform the actual slicing.
* [fdm_materials](https://github.com/Ultimaker/fdm_materials) Required to load a printer that has swappable material profiles.
* [PySerial](https://github.com/pyserial/pyserial) Only required for USB printing support. * [PySerial](https://github.com/pyserial/pyserial) Only required for USB printing support.
* [python-zeroconf](https://github.com/jstasiak/python-zeroconf) Only required to detect mDNS-enabled printers * [python-zeroconf](https://github.com/jstasiak/python-zeroconf) Only required to detect mDNS-enabled printers.
Build scripts Build scripts
------------- -------------

View file

@ -6,6 +6,8 @@ include(CMakeParseArguments)
find_package(PythonInterp 3.5.0 REQUIRED) find_package(PythonInterp 3.5.0 REQUIRED)
add_custom_target(test-verbose COMMAND ${CMAKE_CTEST_COMMAND} --verbose)
function(cura_add_test) function(cura_add_test)
set(_single_args NAME DIRECTORY PYTHONPATH) set(_single_args NAME DIRECTORY PYTHONPATH)
cmake_parse_arguments("" "" "${_single_args}" "" ${ARGN}) cmake_parse_arguments("" "" "${_single_args}" "" ${ARGN})
@ -34,7 +36,7 @@ function(cura_add_test)
if (NOT ${test_exists}) if (NOT ${test_exists})
add_test( add_test(
NAME ${_NAME} NAME ${_NAME}
COMMAND ${PYTHON_EXECUTABLE} -m pytest --junitxml=${CMAKE_BINARY_DIR}/junit-${_NAME}.xml ${_DIRECTORY} COMMAND ${PYTHON_EXECUTABLE} -m pytest --verbose --full-trace --capture=no --no-print-log --junitxml=${CMAKE_BINARY_DIR}/junit-${_NAME}.xml ${_DIRECTORY}
) )
set_tests_properties(${_NAME} PROPERTIES ENVIRONMENT LANG=C) set_tests_properties(${_NAME} PROPERTIES ENVIRONMENT LANG=C)
set_tests_properties(${_NAME} PROPERTIES ENVIRONMENT "PYTHONPATH=${_PYTHONPATH}") set_tests_properties(${_NAME} PROPERTIES ENVIRONMENT "PYTHONPATH=${_PYTHONPATH}")
@ -57,5 +59,13 @@ endforeach()
#Add code style test. #Add code style test.
add_test( add_test(
NAME "code-style" NAME "code-style"
COMMAND ${PYTHON_EXECUTABLE} run_mypy.py WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${PYTHON_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"
COMMAND ${PYTHON_EXECUTABLE} scripts/check_shortcut_keys.py
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
) )

19
contributing.md Normal file
View file

@ -0,0 +1,19 @@
Submitting bug reports
----------------------
Please submit bug reports for all of Cura and CuraEngine to the [Cura repository](https://github.com/Ultimaker/Cura/issues). There will be a template there to fill in. Depending on the type of issue, we will usually ask for the [Cura log](Logging Issues) or a project file.
If a bug report would contain private information, such as a proprietary 3D model, you may also e-mail us. Ask for contact information in the issue.
Bugs related to supporting certain types of printers can usually not be solved by the Cura maintainers, since we don't have access to every 3D printer model in the world either. We have to rely on external contributors to fix this. If it's something simple and obvious, such as a mistake in the start g-code, then we can directly fix it for you, but e.g. issues with USB cable connectivity are impossible for us to debug.
Requesting features
-------------------
The issue template in the Cura repository does not apply to feature requests. You can ignore it.
When requesting a feature, please describe clearly what you need and why you think this is valuable to users or what problem it solves.
Making pull requests
--------------------
If you want to propose a change to Cura's source code, please create a pull request in the appropriate repository (being [Cura](https://github.com/Ultimaker/Cura), [Uranium](https://github.com/Ultimaker/Uranium), [CuraEngine](https://github.com/Ultimaker/CuraEngine), [fdm_materials](https://github.com/Ultimaker/fdm_materials), [libArcus](https://github.com/Ultimaker/libArcus), [cura-build](https://github.com/Ultimaker/cura-build), [cura-build-environment](https://github.com/Ultimaker/cura-build-environment), [libSavitar](https://github.com/Ultimaker/libSavitar), [libCharon](https://github.com/Ultimaker/libCharon) or [cura-binary-data](https://github.com/Ultimaker/cura-binary-data)) and if your change requires changes on multiple of these repositories, please link them together so that we know to merge them together.
Some of these repositories will have automated tests running when you create a pull request, indicated by green check marks or red crosses in the Github web page. If you see a red cross, that means that a test has failed. If the test doesn't fail on the Master branch but does fail on your branch, that indicates that you've probably made a mistake and you need to do that. Click on the cross for more details, or run the test locally by running `cmake . && ctest --verbose`.

View file

@ -26,6 +26,6 @@
<screenshots> <screenshots>
<screenshot type="default" width="1280" height="720">http://software.ultimaker.com/Cura.png</screenshot> <screenshot type="default" width="1280" height="720">http://software.ultimaker.com/Cura.png</screenshot>
</screenshots> </screenshots>
<url type="homepage">https://ultimaker.com/en/products/cura-software?utm_source=cura&utm_medium=software&utm_campaign=resources</url> <url type="homepage">https://ultimaker.com/en/products/cura-software?utm_source=cura&amp;utm_medium=software&amp;utm_campaign=resources</url>
<translation type="gettext">Cura</translation> <translation type="gettext">Cura</translation>
</component> </component>

View file

@ -1,15 +1,18 @@
[Desktop Entry] [Desktop Entry]
Name=Ultimaker Cura Name=Ultimaker Cura
Name[de]=Ultimaker Cura Name[de]=Ultimaker Cura
Name[nl]=Ultimaker Cura
GenericName=3D Printing Software GenericName=3D Printing Software
GenericName[de]=3D-Druck-Software GenericName[de]=3D-Druck-Software
GenericName[nl]=3D-printsoftware
Comment=Cura converts 3D models into paths for a 3D printer. It prepares your print for maximum accuracy, minimum printing time and good reliability with many extra features that make your print come out great. Comment=Cura converts 3D models into paths for a 3D printer. It prepares your print for maximum accuracy, minimum printing time and good reliability with many extra features that make your print come out great.
Comment[de]=Cura wandelt 3D-Modelle in Pfade für einen 3D-Drucker um. Es bereitet Ihren Druck für maximale Genauigkeit, minimale Druckzeit und guter Zuverlässigkeit mit vielen zusätzlichen Funktionen vor, damit Ihr Druck großartig wird. Comment[de]=Cura wandelt 3D-Modelle in Pfade für einen 3D-Drucker um. Es bereitet Ihren Druck für maximale Genauigkeit, minimale Druckzeit und guter Zuverlässigkeit mit vielen zusätzlichen Funktionen vor, damit Ihr Druck großartig wird.
Comment[nl]=Cura converteert 3D-modellen naar paden voor een 3D printer. Het bereidt je print voor om zeer precies, snel en betrouwbaar te kunnen printen, met veel extra functionaliteit om je print er goed uit te laten komen.
Exec=@CMAKE_INSTALL_FULL_BINDIR@/cura %F Exec=@CMAKE_INSTALL_FULL_BINDIR@/cura %F
TryExec=@CMAKE_INSTALL_FULL_BINDIR@/cura TryExec=@CMAKE_INSTALL_FULL_BINDIR@/cura
Icon=cura-icon Icon=cura-icon
Terminal=false Terminal=false
Type=Application Type=Application
MimeType=application/sla;application/vnd.ms-3mfdocument;application/prs.wavefront-obj;image/bmp;image/gif;image/jpeg;image/png;model/x3d+xml; MimeType=model/stl;application/vnd.ms-3mfdocument;application/prs.wavefront-obj;image/bmp;image/gif;image/jpeg;image/png;model/x3d+xml;text/x-gcode;
Categories=Graphics; Categories=Graphics;
Keywords=3D;Printing; Keywords=3D;Printing;Slicer;

View file

@ -6,7 +6,7 @@
<glob-deleteall/> <glob-deleteall/>
<glob pattern="*.3mf"/> <glob pattern="*.3mf"/>
</mime-type> </mime-type>
<mime-type type="application/sla"> <mime-type type="model/stl">
<comment>Computer-aided design and manufacturing format</comment> <comment>Computer-aided design and manufacturing format</comment>
<icon name="unknown"/> <icon name="unknown"/>
<glob-deleteall/> <glob-deleteall/>
@ -19,4 +19,12 @@
<glob-deleteall/> <glob-deleteall/>
<glob pattern="*.obj"/> <glob pattern="*.obj"/>
</mime-type> </mime-type>
<mime-type type="text/x-gcode">
<sub-class-of type="text/plain"/>
<comment>Gcode file</comment>
<icon name="unknown"/>
<glob-deleteall/>
<glob pattern="*.gcode"/>
<glob pattern="*.g"/>
</mime-type>
</mime-info> </mime-info>

126
cura/API/Account.py Normal file
View file

@ -0,0 +1,126 @@
# 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 PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty
from UM.i18n import i18nCatalog
from UM.Message import Message
from cura import UltimakerCloudAuthentication
from cura.OAuth2.AuthorizationService import AuthorizationService
from cura.OAuth2.Models import OAuth2Settings
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
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 Account(QObject):
# Signal emitted when user logged in or out.
loginStateChanged = pyqtSignal(bool)
def __init__(self, application: "CuraApplication", parent = None) -> None:
super().__init__(parent)
self._application = application
self._error_message = None # type: Optional[Message]
self._logged_in = False
self._callback_port = 32118
self._oauth_root = UltimakerCloudAuthentication.CuraCloudAccountAPIRoot
self._oauth_settings = OAuth2Settings(
OAUTH_SERVER_URL= self._oauth_root,
CALLBACK_PORT=self._callback_port,
CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port),
CLIENT_ID="um----------------------------ultimaker_cura",
CLIENT_SCOPES="account.user.read drive.backup.read drive.backup.write packages.download "
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write "
"cura.printjob.read cura.printjob.write cura.mesh.read cura.mesh.write",
AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data",
AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root),
AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root)
)
self._authorization_service = AuthorizationService(self._oauth_settings)
def initialize(self) -> None:
self._authorization_service.initialize(self._application.getPreferences())
self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged)
self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged)
self._authorization_service.loadAuthDataFromPreferences()
## Returns a boolean indicating whether the given authentication is applied against staging or not.
@property
def is_staging(self) -> bool:
return "staging" in self._oauth_root
@pyqtProperty(bool, notify=loginStateChanged)
def isLoggedIn(self) -> bool:
return self._logged_in
def _onLoginStateChanged(self, logged_in: bool = False, error_message: Optional[str] = None) -> None:
if error_message:
if self._error_message:
self._error_message.hide()
self._error_message = Message(error_message, title = i18n_catalog.i18nc("@info:title", "Login failed"))
self._error_message.show()
self._logged_in = False
self.loginStateChanged.emit(False)
return
if self._logged_in != logged_in:
self._logged_in = logged_in
self.loginStateChanged.emit(logged_in)
@pyqtSlot()
def login(self) -> None:
if self._logged_in:
# Nothing to do, user already logged in.
return
self._authorization_service.startAuthorizationFlow()
@pyqtProperty(str, notify=loginStateChanged)
def userName(self):
user_profile = self._authorization_service.getUserProfile()
if not user_profile:
return None
return user_profile.username
@pyqtProperty(str, notify = loginStateChanged)
def profileImageUrl(self):
user_profile = self._authorization_service.getUserProfile()
if not user_profile:
return None
return user_profile.profile_image_url
@pyqtProperty(str, notify=loginStateChanged)
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]]]:
user_profile = self._authorization_service.getUserProfile()
if not user_profile:
return None
return user_profile.__dict__
@pyqtSlot()
def logout(self) -> None:
if not self._logged_in:
return # Nothing to do, user isn't logged in.
self._authorization_service.deleteAuthData()

35
cura/API/Backups.py Normal file
View file

@ -0,0 +1,35 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Tuple, Optional, TYPE_CHECKING, Dict, Any
from cura.Backups.BackupsManager import BackupsManager
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:
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]]]:
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:
return self.manager.restoreBackup(zip_file, meta_data)

View file

@ -0,0 +1,38 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import TYPE_CHECKING
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:
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:
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:
return self.application.getSidebarCustomMenuItems()

View file

@ -0,0 +1,27 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import TYPE_CHECKING
from cura.API.Interface.Settings import Settings
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:
def __init__(self, application: "CuraApplication") -> None:
# API methods specific to the settings portion of the UI
self.settings = Settings(application)

63
cura/API/__init__.py Normal file
View file

@ -0,0 +1,63 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, TYPE_CHECKING
from PyQt5.QtCore import QObject, pyqtProperty
from cura.API.Backups import Backups
from cura.API.Interface import Interface
from cura.API.Account import Account
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):
# For now we use the same API version to be consistent.
__instance = None # type: "CuraAPI"
_application = None # type: CuraApplication
# This is done to ensure that the first time an instance is created, it's forced that the application is set.
# The main reason for this is that we want to prevent consumers of API to have a dependency on CuraApplication.
# Since the API is intended to be used by plugins, the cura application should have already created this.
def __new__(cls, application: Optional["CuraApplication"] = None):
if cls.__instance is None:
if application is None:
raise Exception("Upon first time creation, the application must be set.")
cls.__instance = super(CuraAPI, cls).__new__(cls)
cls._application = application
return cls.__instance
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)
# Interface API
self._interface = Interface(self._application)
def initialize(self) -> None:
self._account.initialize()
@pyqtProperty(QObject, constant = True)
def account(self) -> "Account":
return self._account
@property
def backups(self) -> "Backups":
return self._backups
@property
def interface(self) -> "Interface":
return self._interface

View file

@ -0,0 +1,50 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
# ---------
# General constants used in Cura
# ---------
DEFAULT_CURA_APP_NAME = "cura"
DEFAULT_CURA_DISPLAY_NAME = "Ultimaker Cura"
DEFAULT_CURA_VERSION = "master"
DEFAULT_CURA_BUILD_TYPE = ""
DEFAULT_CURA_DEBUG_MODE = False
DEFAULT_CURA_SDK_VERSION = "6.0.0"
try:
from cura.CuraVersion import CuraAppName # type: ignore
if CuraAppName == "":
CuraAppName = DEFAULT_CURA_APP_NAME
except ImportError:
CuraAppName = DEFAULT_CURA_APP_NAME
try:
from cura.CuraVersion import CuraAppDisplayName # type: ignore
if CuraAppDisplayName == "":
CuraAppDisplayName = DEFAULT_CURA_DISPLAY_NAME
except ImportError:
CuraAppDisplayName = DEFAULT_CURA_DISPLAY_NAME
try:
from cura.CuraVersion import CuraVersion # type: ignore
if CuraVersion == "":
CuraVersion = DEFAULT_CURA_VERSION
except ImportError:
CuraVersion = DEFAULT_CURA_VERSION # [CodeStyle: Reflecting imported value]
try:
from cura.CuraVersion import CuraBuildType # type: ignore
except ImportError:
CuraBuildType = DEFAULT_CURA_BUILD_TYPE
try:
from cura.CuraVersion import CuraDebugMode # type: ignore
except ImportError:
CuraDebugMode = DEFAULT_CURA_DEBUG_MODE
try:
from cura.CuraVersion import CuraSDKVersion # type: ignore
if CuraSDKVersion == "":
CuraSDKVersion = DEFAULT_CURA_SDK_VERSION
except ImportError:
CuraSDKVersion = DEFAULT_CURA_SDK_VERSION

View file

@ -1,6 +1,12 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import List
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Logger import Logger from UM.Logger import Logger
from UM.Math.Polygon import Polygon
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
from UM.Scene.SceneNode import SceneNode
from cura.Arranging.ShapeArray import ShapeArray from cura.Arranging.ShapeArray import ShapeArray
from cura.Scene import ZOffsetDecorator from cura.Scene import ZOffsetDecorator
@ -18,17 +24,20 @@ LocationSuggestion = namedtuple("LocationSuggestion", ["x", "y", "penalty_points
# good locations for objects that you try to put on a build place. # 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 # Different priority schemes can be defined so it alters the behavior while using
# the same logic. # the same logic.
#
# Note: Make sure the scale is the same between ShapeArray objects and the Arrange instance.
class Arrange: class Arrange:
build_volume = None build_volume = None
def __init__(self, x, y, offset_x, offset_y, scale= 1.0): def __init__(self, x, y, offset_x, offset_y, scale= 0.5):
self.shape = (y, x)
self._priority = numpy.zeros((x, y), dtype=numpy.int32)
self._priority_unique_values = []
self._occupied = numpy.zeros((x, y), dtype=numpy.int32)
self._scale = scale # convert input coordinates to arrange coordinates self._scale = scale # convert input coordinates to arrange coordinates
self._offset_x = offset_x world_x, world_y = int(x * self._scale), int(y * self._scale)
self._offset_y = offset_y self._shape = (world_y, world_x)
self._priority = numpy.zeros((world_y, world_x), dtype=numpy.int32) # beware: these are indexed (y, x)
self._priority_unique_values = []
self._occupied = numpy.zeros((world_y, world_x), dtype=numpy.int32) # beware: these are indexed (y, x)
self._offset_x = int(offset_x * self._scale)
self._offset_y = int(offset_y * self._scale)
self._last_priority = 0 self._last_priority = 0
self._is_empty = True self._is_empty = True
@ -39,7 +48,7 @@ class Arrange:
# \param scene_root Root for finding all scene nodes # \param scene_root Root for finding all scene nodes
# \param fixed_nodes Scene nodes to be placed # \param fixed_nodes Scene nodes to be placed
@classmethod @classmethod
def create(cls, scene_root = None, fixed_nodes = None, scale = 0.5, x = 220, y = 220): def create(cls, scene_root = None, fixed_nodes = None, scale = 0.5, x = 350, y = 250, min_offset = 8):
arranger = Arrange(x, y, x // 2, y // 2, scale = scale) arranger = Arrange(x, y, x // 2, y // 2, scale = scale)
arranger.centerFirst() arranger.centerFirst()
@ -52,59 +61,69 @@ class Arrange:
# Place all objects fixed nodes # Place all objects fixed nodes
for fixed_node in fixed_nodes: for fixed_node in fixed_nodes:
vertices = fixed_node.callDecoration("getConvexHull") vertices = fixed_node.callDecoration("getConvexHullHead") or fixed_node.callDecoration("getConvexHull")
if not vertices: if not vertices:
continue continue
vertices = vertices.getMinkowskiHull(Polygon.approximatedCircle(min_offset))
points = copy.deepcopy(vertices._points) points = copy.deepcopy(vertices._points)
# After scaling (like up to 0.1 mm) the node might not have points
if len(points) == 0:
continue
shape_arr = ShapeArray.fromPolygon(points, scale = scale) shape_arr = ShapeArray.fromPolygon(points, scale = scale)
arranger.place(0, 0, shape_arr) arranger.place(0, 0, shape_arr)
# If a build volume was set, add the disallowed areas # If a build volume was set, add the disallowed areas
if Arrange.build_volume: if Arrange.build_volume:
disallowed_areas = Arrange.build_volume.getDisallowedAreas() disallowed_areas = Arrange.build_volume.getDisallowedAreasNoBrim()
for area in disallowed_areas: for area in disallowed_areas:
points = copy.deepcopy(area._points) points = copy.deepcopy(area._points)
shape_arr = ShapeArray.fromPolygon(points, scale = scale) shape_arr = ShapeArray.fromPolygon(points, scale = scale)
arranger.place(0, 0, shape_arr, update_empty = False) arranger.place(0, 0, shape_arr, update_empty = False)
return arranger return arranger
## This resets the optimization for finding location based on size
def resetLastPriority(self):
self._last_priority = 0
## Find placement for a node (using offset shape) and place it (using hull shape) ## Find placement for a node (using offset shape) and place it (using hull shape)
# return the nodes that should be placed # return the nodes that should be placed
# \param node # \param node
# \param offset_shape_arr ShapeArray with offset, used to find location # \param offset_shape_arr ShapeArray with offset, for placing the shape
# \param hull_shape_arr ShapeArray without offset, for placing the shape # \param hull_shape_arr ShapeArray without offset, used to find location
def findNodePlacement(self, node, offset_shape_arr, hull_shape_arr, step = 1): def findNodePlacement(self, node: SceneNode, offset_shape_arr: ShapeArray, hull_shape_arr: ShapeArray, step = 1):
new_node = copy.deepcopy(node)
best_spot = self.bestSpot( best_spot = self.bestSpot(
offset_shape_arr, start_prio = self._last_priority, step = step) hull_shape_arr, start_prio = self._last_priority, step = step)
x, y = best_spot.x, best_spot.y x, y = best_spot.x, best_spot.y
# Save the last priority. # Save the last priority.
self._last_priority = best_spot.priority self._last_priority = best_spot.priority
# Ensure that the object is above the build platform # Ensure that the object is above the build platform
new_node.removeDecorator(ZOffsetDecorator.ZOffsetDecorator) node.removeDecorator(ZOffsetDecorator.ZOffsetDecorator)
if new_node.getBoundingBox(): bbox = node.getBoundingBox()
center_y = new_node.getWorldPosition().y - new_node.getBoundingBox().bottom if bbox:
center_y = node.getWorldPosition().y - bbox.bottom
else: else:
center_y = 0 center_y = 0
if x is not None: # We could find a place if x is not None: # We could find a place
new_node.setPosition(Vector(x, center_y, y)) node.setPosition(Vector(x, center_y, y))
found_spot = True found_spot = True
self.place(x, y, hull_shape_arr) # place the object in arranger self.place(x, y, offset_shape_arr) # place the object in arranger
else: else:
Logger.log("d", "Could not find spot!"), Logger.log("d", "Could not find spot!"),
found_spot = False found_spot = False
new_node.setPosition(Vector(200, center_y, 100)) node.setPosition(Vector(200, center_y, 100))
return new_node, found_spot return found_spot
## Fill priority, center is best. Lower value is better ## Fill priority, center is best. Lower value is better
# This is a strategy for the arranger. # This is a strategy for the arranger.
def centerFirst(self): def centerFirst(self):
# Square distance: creates a more round shape # Square distance: creates a more round shape
self._priority = numpy.fromfunction( self._priority = numpy.fromfunction(
lambda i, j: (self._offset_x - i) ** 2 + (self._offset_y - j) ** 2, self.shape, dtype=numpy.int32) lambda j, i: (self._offset_x - i) ** 2 + (self._offset_y - j) ** 2, self._shape, dtype=numpy.int32)
self._priority_unique_values = numpy.unique(self._priority) self._priority_unique_values = numpy.unique(self._priority)
self._priority_unique_values.sort() self._priority_unique_values.sort()
@ -112,7 +131,7 @@ class Arrange:
# This is a strategy for the arranger. # This is a strategy for the arranger.
def backFirst(self): def backFirst(self):
self._priority = numpy.fromfunction( self._priority = numpy.fromfunction(
lambda i, j: 10 * j + abs(self._offset_x - i), self.shape, dtype=numpy.int32) lambda j, i: 10 * j + abs(self._offset_x - i), self._shape, dtype=numpy.int32)
self._priority_unique_values = numpy.unique(self._priority) self._priority_unique_values = numpy.unique(self._priority)
self._priority_unique_values.sort() self._priority_unique_values.sort()
@ -126,9 +145,15 @@ class Arrange:
y = int(self._scale * y) y = int(self._scale * y)
offset_x = x + self._offset_x + shape_arr.offset_x offset_x = x + self._offset_x + shape_arr.offset_x
offset_y = y + self._offset_y + shape_arr.offset_y offset_y = y + self._offset_y + shape_arr.offset_y
if offset_x < 0 or offset_y < 0:
return None # out of bounds in self._occupied
occupied_x_max = offset_x + shape_arr.arr.shape[1]
occupied_y_max = offset_y + shape_arr.arr.shape[0]
if occupied_x_max > self._occupied.shape[1] + 1 or occupied_y_max > self._occupied.shape[0] + 1:
return None # out of bounds in self._occupied
occupied_slice = self._occupied[ occupied_slice = self._occupied[
offset_y:offset_y + shape_arr.arr.shape[0], offset_y:occupied_y_max,
offset_x:offset_x + shape_arr.arr.shape[1]] offset_x:occupied_x_max]
try: try:
if numpy.any(occupied_slice[numpy.where(shape_arr.arr == 1)]): if numpy.any(occupied_slice[numpy.where(shape_arr.arr == 1)]):
return None return None
@ -140,7 +165,7 @@ class Arrange:
return numpy.sum(prio_slice[numpy.where(shape_arr.arr == 1)]) return numpy.sum(prio_slice[numpy.where(shape_arr.arr == 1)])
## Find "best" spot for ShapeArray ## Find "best" spot for ShapeArray
# Return namedtuple with properties x, y, penalty_points, priority # Return namedtuple with properties x, y, penalty_points, priority.
# \param shape_arr ShapeArray # \param shape_arr ShapeArray
# \param start_prio Start with this priority value (and skip the ones before) # \param start_prio Start with this priority value (and skip the ones before)
# \param step Slicing value, higher = more skips = faster but less accurate # \param step Slicing value, higher = more skips = faster but less accurate
@ -153,12 +178,11 @@ class Arrange:
for priority in self._priority_unique_values[start_idx::step]: for priority in self._priority_unique_values[start_idx::step]:
tryout_idx = numpy.where(self._priority == priority) tryout_idx = numpy.where(self._priority == priority)
for idx in range(len(tryout_idx[0])): for idx in range(len(tryout_idx[0])):
x = tryout_idx[0][idx] x = tryout_idx[1][idx]
y = tryout_idx[1][idx] y = tryout_idx[0][idx]
projected_x = x - self._offset_x projected_x = int((x - self._offset_x) / self._scale)
projected_y = y - self._offset_y projected_y = int((y - self._offset_y) / self._scale)
# array to "world" coordinates
penalty_points = self.checkShape(projected_x, projected_y, shape_arr) penalty_points = self.checkShape(projected_x, projected_y, shape_arr)
if penalty_points is not None: if penalty_points is not None:
return LocationSuggestion(x = projected_x, y = projected_y, penalty_points = penalty_points, priority = priority) return LocationSuggestion(x = projected_x, y = projected_y, penalty_points = penalty_points, priority = priority)
@ -191,8 +215,7 @@ class Arrange:
# Set priority to low (= high number), so it won't get picked at trying out. # Set priority to low (= high number), so it won't get picked at trying out.
prio_slice = self._priority[min_y:max_y, min_x:max_x] prio_slice = self._priority[min_y:max_y, min_x:max_x]
prio_slice[numpy.where(shape_arr.arr[ prio_slice[new_occupied] = 999
min_y - offset_y:max_y - offset_y, min_x - offset_x:max_x - offset_x] == 1)] = 999
@property @property
def isEmpty(self): def isEmpty(self):

View file

@ -1,6 +1,7 @@
# Copyright (c) 2017 Ultimaker B.V. # Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from UM.Application import Application
from UM.Job import Job from UM.Job import Job
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
@ -17,15 +18,16 @@ from cura.Arranging.ShapeArray import ShapeArray
from typing import List from typing import List
## Do arrangements on multiple build plates (aka builtiplexer)
class ArrangeArray: class ArrangeArray:
def __init__(self, x: int, y: int, fixed_nodes: List[SceneNode]): def __init__(self, x: int, y: int, fixed_nodes: List[SceneNode]) -> None:
self._x = x self._x = x
self._y = y self._y = y
self._fixed_nodes = fixed_nodes self._fixed_nodes = fixed_nodes
self._count = 0 self._count = 0
self._first_empty = None self._first_empty = None
self._has_empty = False self._has_empty = False
self._arrange = [] self._arrange = [] # type: List[Arrange]
def _update_first_empty(self): def _update_first_empty(self):
for i, a in enumerate(self._arrange): for i, a in enumerate(self._arrange):
@ -49,13 +51,13 @@ class ArrangeArray:
return self._arrange[index] return self._arrange[index]
def getFirstEmpty(self): def getFirstEmpty(self):
if not self._is_empty: if not self._has_empty:
self.add() self.add()
return self._arrange[self._first_empty] return self._arrange[self._first_empty]
class ArrangeObjectsAllBuildPlatesJob(Job): class ArrangeObjectsAllBuildPlatesJob(Job):
def __init__(self, nodes: List[SceneNode], min_offset = 8): def __init__(self, nodes: List[SceneNode], min_offset = 8) -> None:
super().__init__() super().__init__()
self._nodes = nodes self._nodes = nodes
self._min_offset = min_offset self._min_offset = min_offset
@ -79,7 +81,11 @@ class ArrangeObjectsAllBuildPlatesJob(Job):
nodes_arr.sort(key=lambda item: item[0]) nodes_arr.sort(key=lambda item: item[0])
nodes_arr.reverse() nodes_arr.reverse()
x, y = 200, 200 global_container_stack = Application.getInstance().getGlobalContainerStack()
machine_width = global_container_stack.getProperty("machine_width", "value")
machine_depth = global_container_stack.getProperty("machine_depth", "value")
x, y = machine_width, machine_depth
arrange_array = ArrangeArray(x = x, y = y, fixed_nodes = []) arrange_array = ArrangeArray(x = x, y = y, fixed_nodes = [])
arrange_array.add() arrange_array.add()
@ -93,27 +99,18 @@ class ArrangeObjectsAllBuildPlatesJob(Job):
for idx, (size, node, offset_shape_arr, hull_shape_arr) in enumerate(nodes_arr): 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, # 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). # it will also not fit for the next object (while what can be untrue).
# We also skip possibilities by slicing through the possibilities (step = 10)
try_placement = True try_placement = True
current_build_plate_number = 0 # always start with the first one current_build_plate_number = 0 # always start with the first one
# # Only for first build plate
# if last_size == size and last_build_plate_number == current_build_plate_number:
# # This optimization works if many of the objects have the same size
# # Continue with same build plate number
# start_priority = last_priority
# else:
# start_priority = 0
while try_placement: while try_placement:
# make sure that current_build_plate_number is not going crazy or you'll have a lot of arrange objects # make sure that current_build_plate_number is not going crazy or you'll have a lot of arrange objects
while current_build_plate_number >= arrange_array.count(): while current_build_plate_number >= arrange_array.count():
arrange_array.add() arrange_array.add()
arranger = arrange_array.get(current_build_plate_number) arranger = arrange_array.get(current_build_plate_number)
best_spot = arranger.bestSpot(offset_shape_arr, start_prio=start_priority, step=10) best_spot = arranger.bestSpot(hull_shape_arr, start_prio=start_priority)
x, y = best_spot.x, best_spot.y x, y = best_spot.x, best_spot.y
node.removeDecorator(ZOffsetDecorator) node.removeDecorator(ZOffsetDecorator)
if node.getBoundingBox(): if node.getBoundingBox():
@ -121,7 +118,7 @@ class ArrangeObjectsAllBuildPlatesJob(Job):
else: else:
center_y = 0 center_y = 0
if x is not None: # We could find a place if x is not None: # We could find a place
arranger.place(x, y, hull_shape_arr) # place the object in the arranger arranger.place(x, y, offset_shape_arr) # place the object in the arranger
node.callDecoration("setBuildPlateNumber", current_build_plate_number) node.callDecoration("setBuildPlateNumber", current_build_plate_number)
grouped_operation.addOperation(TranslateOperation(node, Vector(x, center_y, y), set_position = True)) grouped_operation.addOperation(TranslateOperation(node, Vector(x, center_y, y), set_position = True))

View file

@ -1,6 +1,7 @@
# Copyright (c) 2017 Ultimaker B.V. # Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from UM.Application import Application
from UM.Job import Job from UM.Job import Job
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
@ -19,7 +20,7 @@ from typing import List
class ArrangeObjectsJob(Job): class ArrangeObjectsJob(Job):
def __init__(self, nodes: List[SceneNode], fixed_nodes: List[SceneNode], min_offset = 8): def __init__(self, nodes: List[SceneNode], fixed_nodes: List[SceneNode], min_offset = 8) -> None:
super().__init__() super().__init__()
self._nodes = nodes self._nodes = nodes
self._fixed_nodes = fixed_nodes self._fixed_nodes = fixed_nodes
@ -32,12 +33,26 @@ class ArrangeObjectsJob(Job):
progress = 0, progress = 0,
title = i18n_catalog.i18nc("@info:title", "Finding Location")) title = i18n_catalog.i18nc("@info:title", "Finding Location"))
status_message.show() status_message.show()
arranger = Arrange.create(fixed_nodes = self._fixed_nodes) 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 # Collect nodes to be placed
nodes_arr = [] # fill with (size, node, offset_shape_arr, hull_shape_arr) nodes_arr = [] # fill with (size, node, offset_shape_arr, hull_shape_arr)
for node in self._nodes: for node in self._nodes:
offset_shape_arr, hull_shape_arr = ShapeArray.fromNode(node, min_offset = self._min_offset) 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)) 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. # Sort the nodes with the biggest area first.
@ -50,15 +65,15 @@ class ArrangeObjectsJob(Job):
last_size = None last_size = None
grouped_operation = GroupedOperation() grouped_operation = GroupedOperation()
found_solution_for_all = True found_solution_for_all = True
not_fit_count = 0
for idx, (size, node, offset_shape_arr, hull_shape_arr) in enumerate(nodes_arr): 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, # 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). # it will also not fit for the next object (while what can be untrue).
# We also skip possibilities by slicing through the possibilities (step = 10)
if last_size == size: # This optimization works if many of the objects have the same size if last_size == size: # This optimization works if many of the objects have the same size
start_priority = last_priority start_priority = last_priority
else: else:
start_priority = 0 start_priority = 0
best_spot = arranger.bestSpot(offset_shape_arr, start_prio=start_priority, step=10) best_spot = arranger.bestSpot(hull_shape_arr, start_prio = start_priority)
x, y = best_spot.x, best_spot.y x, y = best_spot.x, best_spot.y
node.removeDecorator(ZOffsetDecorator) node.removeDecorator(ZOffsetDecorator)
if node.getBoundingBox(): if node.getBoundingBox():
@ -69,13 +84,13 @@ class ArrangeObjectsJob(Job):
last_size = size last_size = size
last_priority = best_spot.priority last_priority = best_spot.priority
arranger.place(x, y, hull_shape_arr) # take place before the next one 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)) grouped_operation.addOperation(TranslateOperation(node, Vector(x, center_y, y), set_position = True))
else: else:
Logger.log("d", "Arrange all: could not find spot!") Logger.log("d", "Arrange all: could not find spot!")
found_solution_for_all = False found_solution_for_all = False
grouped_operation.addOperation(TranslateOperation(node, Vector(200, center_y, - idx * 20), set_position = True)) 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) status_message.setProgress((idx + 1) / len(nodes_arr) * 100)
Job.yieldThread() Job.yieldThread()

View file

@ -42,7 +42,7 @@ class ShapeArray:
# \param min_offset offset for the offset ShapeArray # \param min_offset offset for the offset ShapeArray
# \param scale scale the coordinates # \param scale scale the coordinates
@classmethod @classmethod
def fromNode(cls, node, min_offset, scale = 0.5): def fromNode(cls, node, min_offset, scale = 0.5, include_children = False):
transform = node._transformation transform = node._transformation
transform_x = transform._data[0][3] transform_x = transform._data[0][3]
transform_y = transform._data[2][3] transform_y = transform._data[2][3]
@ -52,6 +52,21 @@ class ShapeArray:
return None, None return None, None
# For one_at_a_time printing you need the convex hull head. # For one_at_a_time printing you need the convex hull head.
hull_head_verts = node.callDecoration("getConvexHullHead") or hull_verts hull_head_verts = node.callDecoration("getConvexHullHead") or hull_verts
if hull_head_verts is None:
hull_head_verts = Polygon()
# If the child-nodes are included, adjust convex hulls as well:
if include_children:
children = node.getAllChildren()
if not children is None:
for child in children:
# 'Inefficient' combination of convex hulls through known code rather than mess it up:
child_hull = child.callDecoration("getConvexHull")
if not child_hull is None:
hull_verts = hull_verts.unionConvexHulls(child_hull)
child_hull_head = child.callDecoration("getConvexHullHead") or child_hull
if not child_hull_head is None:
hull_head_verts = hull_head_verts.unionConvexHulls(child_hull_head)
offset_verts = hull_head_verts.getMinkowskiHull(Polygon.approximatedCircle(min_offset)) offset_verts = hull_head_verts.getMinkowskiHull(Polygon.approximatedCircle(min_offset))
offset_points = copy.deepcopy(offset_verts._points) # x, y offset_points = copy.deepcopy(offset_verts._points) # x, y
@ -74,7 +89,7 @@ class ShapeArray:
# \param vertices # \param vertices
@classmethod @classmethod
def arrayFromPolygon(cls, shape, vertices): def arrayFromPolygon(cls, shape, vertices):
base_array = numpy.zeros(shape, dtype=float) # Initialize your array of zeros 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 fill = numpy.ones(base_array.shape) * True # Initialize boolean array defining shape fill

52
cura/AutoSave.py Normal file
View file

@ -0,0 +1,52 @@
# Copyright (c) 2016 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import QTimer
from UM.Logger import Logger
class AutoSave:
def __init__(self, application):
self._application = application
self._application.getPreferences().preferenceChanged.connect(self._triggerTimer)
self._global_stack = None
self._application.getPreferences().addPreference("cura/autosave_delay", 1000 * 10)
self._change_timer = QTimer()
self._change_timer.setInterval(self._application.getPreferences().getValue("cura/autosave_delay"))
self._change_timer.setSingleShot(True)
self._saving = False
def initialize(self):
# only initialise if the application is created and has started
self._change_timer.timeout.connect(self._onTimeout)
self._application.globalContainerStackChanged.connect(self._onGlobalStackChanged)
self._onGlobalStackChanged()
self._triggerTimer()
def _triggerTimer(self, *args):
if not self._saving:
self._change_timer.start()
def _onGlobalStackChanged(self):
if self._global_stack:
self._global_stack.propertyChanged.disconnect(self._triggerTimer)
self._global_stack.containersChanged.disconnect(self._triggerTimer)
self._global_stack = self._application.getGlobalContainerStack()
if self._global_stack:
self._global_stack.propertyChanged.connect(self._triggerTimer)
self._global_stack.containersChanged.connect(self._triggerTimer)
def _onTimeout(self):
self._saving = True # To prevent the save process from triggering another autosave.
Logger.log("d", "Autosaving preferences, instances and profiles")
self._application.saveSettings()
self._saving = False

152
cura/Backups/Backup.py Normal file
View file

@ -0,0 +1,152 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import io
import os
import re
import shutil
from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile
from typing import Dict, Optional, TYPE_CHECKING
from UM import i18nCatalog
from UM.Logger import Logger
from UM.Message import Message
from UM.Platform import Platform
from UM.Resources import Resources
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"]
# Re-use translation catalog.
catalog = i18nCatalog("cura")
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:
cura_release = self._application.getVersion()
version_data_dir = Resources.getDataStoragePath()
Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir)
# Ensure all current settings are saved.
self._application.saveSettings()
# We copy the preferences file to the user data directory in Linux as it's in a different location there.
# When restoring a backup on Linux, we move it back.
if Platform.isLinux(): #TODO: This should check for the config directory not being the same as the data directory, rather than hard-coding that to Linux systems.
preferences_file_name = self._application.getApplicationName()
preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name))
backup_preferences_file = os.path.join(version_data_dir, "{}.cfg".format(preferences_file_name))
if os.path.exists(preferences_file) and (not os.path.exists(backup_preferences_file) or not os.path.samefile(preferences_file, backup_preferences_file)):
Logger.log("d", "Copying preferences file from %s to %s", preferences_file, backup_preferences_file)
shutil.copyfile(preferences_file, backup_preferences_file)
# Create an empty buffer and write the archive to it.
buffer = io.BytesIO()
archive = self._makeArchive(buffer, version_data_dir)
if archive is None:
return
files = archive.namelist()
# Count the metadata items. We do this in a rather naive way at the moment.
machine_count = len([s for s in files if "machine_instances/" in s]) - 1
material_count = len([s for s in files if "materials/" in s]) - 1
profile_count = len([s for s in files if "quality_changes/" in s]) - 1
plugin_count = len([s for s in files if "plugin.json" in s])
# Store the archive and metadata so the BackupManager can fetch them when needed.
self.zip_file = buffer.getvalue()
self.meta_data = {
"cura_release": cura_release,
"machine_count": str(machine_count),
"material_count": str(material_count),
"profile_count": str(profile_count),
"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]:
ignore_string = re.compile("|".join(self.IGNORED_FILES))
try:
archive = ZipFile(buffer, "w", ZIP_DEFLATED)
for root, folders, files in os.walk(root_path):
for item_name in folders + files:
absolute_path = os.path.join(root, item_name)
if ignore_string.search(absolute_path):
continue
archive.write(absolute_path, absolute_path[len(root_path) + len(os.sep):])
archive.close()
return archive
except (IOError, OSError, BadZipfile) as error:
Logger.log("e", "Could not create archive from user data directory: %s", error)
self._showMessage(
self.catalog.i18nc("@info:backup_failed",
"Could not create archive from user data directory: {}".format(error)))
return None
## Show a UI message.
def _showMessage(self, message: str) -> None:
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:
if not self.zip_file or not self.meta_data or not self.meta_data.get("cura_release", None):
# We can restore without the minimum required information.
Logger.log("w", "Tried to restore a Cura backup without having proper data or meta data.")
self._showMessage(
self.catalog.i18nc("@info:backup_failed",
"Tried to restore a Cura backup without having proper data or meta data."))
return False
current_version = self._application.getVersion()
version_to_restore = self.meta_data.get("cura_release", "master")
if current_version < version_to_restore:
# Cannot restore version newer than current because settings might have changed.
Logger.log("d", "Tried to restore a Cura backup of version {version_to_restore} with cura version {current_version}".format(version_to_restore = version_to_restore, current_version = current_version))
self._showMessage(
self.catalog.i18nc("@info:backup_failed",
"Tried to restore a Cura backup that is higher than the current version."))
return False
version_data_dir = Resources.getDataStoragePath()
archive = ZipFile(io.BytesIO(self.zip_file), "r")
extracted = self._extractArchive(archive, version_data_dir)
# Under Linux, preferences are stored elsewhere, so we copy the file to there.
if Platform.isLinux():
preferences_file_name = self._application.getApplicationName()
preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name))
backup_preferences_file = os.path.join(version_data_dir, "{}.cfg".format(preferences_file_name))
Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file)
shutil.move(backup_preferences_file, preferences_file)
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:
Logger.log("d", "Removing current data in location: %s", target_path)
Resources.factoryReset()
Logger.log("d", "Extracting backup to location: %s", target_path)
archive.extractall(target_path)
return True

View file

@ -0,0 +1,58 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Dict, Optional, Tuple, TYPE_CHECKING
from UM.Logger import Logger
from cura.Backups.Backup import Backup
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
## The BackupsManager is responsible for managing the creating and restoring of
# back-ups.
#
# Back-ups themselves are represented in a different class.
class BackupsManager:
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]]]:
self._disableAutoSave()
backup = Backup(self._application)
backup.makeFromCurrent()
self._enableAutoSave()
# 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:
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.")
return
self._disableAutoSave()
backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data)
restored = backup.restore()
if restored:
# At this point, Cura will need to restart for the changes to take effect.
# We don't want to store the data at this point as that would override the just-restored backup.
self._application.windowClosed(save_data = False)
## Here we try to disable the auto-save plug-in as it might interfere with
# restoring a back-up.
def _disableAutoSave(self) -> None:
self._application.setSaveDataEnabled(False)
## Re-enable auto-save after we're done.
def _enableAutoSave(self) -> None:
self._application.setSaveDataEnabled(True)

0
cura/Backups/__init__.py Normal file
View file

View file

@ -1,14 +1,13 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from UM.Scene.Camera import Camera
from cura.Scene.CuraSceneNode import CuraSceneNode from cura.Scene.CuraSceneNode import CuraSceneNode
from cura.Settings.ExtruderManager import ExtruderManager from cura.Settings.ExtruderManager import ExtruderManager
from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Application import Application #To modify the maximum zoom level.
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from UM.Scene.Platform import Platform from UM.Scene.Platform import Platform
from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Application import Application
from UM.Resources import Resources from UM.Resources import Resources
from UM.Mesh.MeshBuilder import MeshBuilder from UM.Mesh.MeshBuilder import MeshBuilder
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
@ -25,10 +24,11 @@ catalog = i18nCatalog("cura")
import numpy import numpy
import math import math
import copy
from typing import List, Optional from typing import List, Optional
# Setting for clearance around the prime # Radius of disallowed area in mm around prime. I.e. how much distance to keep from prime position.
PRIME_CLEARANCE = 6.5 PRIME_CLEARANCE = 6.5
@ -36,8 +36,10 @@ PRIME_CLEARANCE = 6.5
class BuildVolume(SceneNode): class BuildVolume(SceneNode):
raftThicknessChanged = Signal() raftThicknessChanged = Signal()
def __init__(self, parent = None): def __init__(self, application, parent = None):
super().__init__(parent) super().__init__(parent)
self._application = application
self._machine_manager = self._application.getMachineManager()
self._volume_outline_color = None self._volume_outline_color = None
self._x_axis_color = None self._x_axis_color = None
@ -46,10 +48,10 @@ class BuildVolume(SceneNode):
self._disallowed_area_color = None self._disallowed_area_color = None
self._error_area_color = None self._error_area_color = None
self._width = 0 self._width = 0 #type: float
self._height = 0 self._height = 0 #type: float
self._depth = 0 self._depth = 0 #type: float
self._shape = "" self._shape = "" #type: str
self._shader = None self._shader = None
@ -61,6 +63,7 @@ class BuildVolume(SceneNode):
self._grid_shader = None self._grid_shader = None
self._disallowed_areas = [] self._disallowed_areas = []
self._disallowed_areas_no_brim = []
self._disallowed_area_mesh = None self._disallowed_area_mesh = None
self._error_areas = [] self._error_areas = []
@ -80,14 +83,21 @@ class BuildVolume(SceneNode):
" with printed models."), title = catalog.i18nc("@info:title", "Build Volume")) " with printed models."), title = catalog.i18nc("@info:title", "Build Volume"))
self._global_container_stack = None self._global_container_stack = None
Application.getInstance().globalContainerStackChanged.connect(self._onStackChanged)
self._stack_change_timer = QTimer()
self._stack_change_timer.setInterval(100)
self._stack_change_timer.setSingleShot(True)
self._stack_change_timer.timeout.connect(self._onStackChangeTimerFinished)
self._application.globalContainerStackChanged.connect(self._onStackChanged)
self._onStackChanged() self._onStackChanged()
self._engine_ready = False self._engine_ready = False
Application.getInstance().engineCreatedSignal.connect(self._onEngineCreated) self._application.engineCreatedSignal.connect(self._onEngineCreated)
self._has_errors = False self._has_errors = False
Application.getInstance().getController().getScene().sceneChanged.connect(self._onSceneChanged) self._application.getController().getScene().sceneChanged.connect(self._onSceneChanged)
#Objects loaded at the moment. We are connected to the property changed events of these objects. #Objects loaded at the moment. We are connected to the property changed events of these objects.
self._scene_objects = set() self._scene_objects = set()
@ -105,24 +115,26 @@ class BuildVolume(SceneNode):
# Must be after setting _build_volume_message, apparently that is used in getMachineManager. # Must be after setting _build_volume_message, apparently that is used in getMachineManager.
# activeQualityChanged is always emitted after setActiveVariant, setActiveMaterial and setActiveQuality. # activeQualityChanged is always emitted after setActiveVariant, setActiveMaterial and setActiveQuality.
# Therefore this works. # Therefore this works.
Application.getInstance().getMachineManager().activeQualityChanged.connect(self._onStackChanged) self._machine_manager.activeQualityChanged.connect(self._onStackChanged)
# This should also ways work, and it is semantically more correct, # This should also ways work, and it is semantically more correct,
# but it does not update the disallowed areas after material change # but it does not update the disallowed areas after material change
Application.getInstance().getMachineManager().activeStackChanged.connect(self._onStackChanged) self._machine_manager.activeStackChanged.connect(self._onStackChanged)
# Enable and disable extruder # Enable and disable extruder
Application.getInstance().getMachineManager().extruderChanged.connect(self.updateNodeBoundaryCheck) self._machine_manager.extruderChanged.connect(self.updateNodeBoundaryCheck)
# list of settings which were updated # list of settings which were updated
self._changed_settings_since_last_rebuild = [] self._changed_settings_since_last_rebuild = []
def _onSceneChanged(self, source): def _onSceneChanged(self, source):
if self._global_container_stack: if self._global_container_stack:
# Ignore anything that is not something we can slice in the first place!
if source.callDecoration("isSliceable"):
self._scene_change_timer.start() self._scene_change_timer.start()
def _onSceneChangeTimerFinished(self): def _onSceneChangeTimerFinished(self):
root = Application.getInstance().getController().getScene().getRoot() root = self._application.getController().getScene().getRoot()
new_scene_objects = set(node for node in BreadthFirstIterator(root) if node.callDecoration("isSliceable")) new_scene_objects = set(node for node in BreadthFirstIterator(root) if node.callDecoration("isSliceable"))
if new_scene_objects != self._scene_objects: if new_scene_objects != self._scene_objects:
for node in new_scene_objects - self._scene_objects: #Nodes that were added to the scene. for node in new_scene_objects - self._scene_objects: #Nodes that were added to the scene.
@ -136,7 +148,7 @@ class BuildVolume(SceneNode):
if active_extruder_changed is not None: if active_extruder_changed is not None:
node.callDecoration("getActiveExtruderChangedSignal").disconnect(self._updateDisallowedAreasAndRebuild) node.callDecoration("getActiveExtruderChangedSignal").disconnect(self._updateDisallowedAreasAndRebuild)
node.decoratorsChanged.disconnect(self._updateNodeListeners) node.decoratorsChanged.disconnect(self._updateNodeListeners)
self._updateDisallowedAreasAndRebuild() # make sure we didn't miss anything before we updated the node listeners self.rebuild()
self._scene_objects = new_scene_objects self._scene_objects = new_scene_objects
self._onSettingPropertyChanged("print_sequence", "value") # Create fake event, so right settings are triggered. self._onSettingPropertyChanged("print_sequence", "value") # Create fake event, so right settings are triggered.
@ -152,25 +164,34 @@ class BuildVolume(SceneNode):
if active_extruder_changed is not None: if active_extruder_changed is not None:
active_extruder_changed.connect(self._updateDisallowedAreasAndRebuild) active_extruder_changed.connect(self._updateDisallowedAreasAndRebuild)
def setWidth(self, width): def setWidth(self, width: float) -> None:
if width is not None: if width is not None:
self._width = width self._width = width
def setHeight(self, height): def setHeight(self, height: float) -> None:
if height is not None: if height is not None:
self._height = height self._height = height
def setDepth(self, depth): def setDepth(self, depth: float) -> None:
if depth is not None: if depth is not None:
self._depth = depth self._depth = depth
def setShape(self, shape: str): def setShape(self, shape: str) -> None:
if shape: if shape:
self._shape = shape self._shape = shape
## Get the length of the 3D diagonal through the build volume.
#
# This gives a sense of the scale of the build volume in general.
def getDiagonalSize(self) -> float:
return math.sqrt(self._width * self._width + self._height * self._height + self._depth * self._depth)
def getDisallowedAreas(self) -> List[Polygon]: def getDisallowedAreas(self) -> List[Polygon]:
return self._disallowed_areas return self._disallowed_areas
def getDisallowedAreasNoBrim(self) -> List[Polygon]:
return self._disallowed_areas_no_brim
def setDisallowedAreas(self, areas: List[Polygon]): def setDisallowedAreas(self, areas: List[Polygon]):
self._disallowed_areas = areas self._disallowed_areas = areas
@ -181,7 +202,7 @@ class BuildVolume(SceneNode):
if not self._shader: if not self._shader:
self._shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "default.shader")) self._shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "default.shader"))
self._grid_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "grid.shader")) self._grid_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "grid.shader"))
theme = Application.getInstance().getTheme() theme = self._application.getTheme()
self._grid_shader.setUniformValue("u_plateColor", Color(*theme.getColor("buildplate").getRgb())) self._grid_shader.setUniformValue("u_plateColor", Color(*theme.getColor("buildplate").getRgb()))
self._grid_shader.setUniformValue("u_gridColor0", Color(*theme.getColor("buildplate_grid").getRgb())) self._grid_shader.setUniformValue("u_gridColor0", Color(*theme.getColor("buildplate_grid").getRgb()))
self._grid_shader.setUniformValue("u_gridColor1", Color(*theme.getColor("buildplate_grid_minor").getRgb())) self._grid_shader.setUniformValue("u_gridColor1", Color(*theme.getColor("buildplate_grid_minor").getRgb()))
@ -201,7 +222,7 @@ class BuildVolume(SceneNode):
## For every sliceable node, update node._outside_buildarea ## For every sliceable node, update node._outside_buildarea
# #
def updateNodeBoundaryCheck(self): def updateNodeBoundaryCheck(self):
root = Application.getInstance().getController().getScene().getRoot() root = self._application.getController().getScene().getRoot()
nodes = list(BreadthFirstIterator(root)) nodes = list(BreadthFirstIterator(root))
group_nodes = [] group_nodes = []
@ -230,6 +251,8 @@ class BuildVolume(SceneNode):
# Mark the node as outside build volume if the set extruder is disabled # Mark the node as outside build volume if the set extruder is disabled
extruder_position = node.callDecoration("getActiveExtruderPosition") extruder_position = node.callDecoration("getActiveExtruderPosition")
if extruder_position not in self._global_container_stack.extruders:
continue
if not self._global_container_stack.extruders[extruder_position].isEnabled: if not self._global_container_stack.extruders[extruder_position].isEnabled:
node.setOutsideBuildArea(True) node.setOutsideBuildArea(True)
continue continue
@ -289,11 +312,11 @@ class BuildVolume(SceneNode):
if not self._width or not self._height or not self._depth: if not self._width or not self._height or not self._depth:
return return
if not Application.getInstance()._engine: if not self._engine_ready:
return return
if not self._volume_outline_color: if not self._volume_outline_color:
theme = Application.getInstance().getTheme() theme = self._application.getTheme()
self._volume_outline_color = Color(*theme.getColor("volume_outline").getRgb()) self._volume_outline_color = Color(*theme.getColor("volume_outline").getRgb())
self._x_axis_color = Color(*theme.getColor("x_axis").getRgb()) self._x_axis_color = Color(*theme.getColor("x_axis").getRgb())
self._y_axis_color = Color(*theme.getColor("y_axis").getRgb()) self._y_axis_color = Color(*theme.getColor("y_axis").getRgb())
@ -455,7 +478,7 @@ class BuildVolume(SceneNode):
minimum = Vector(min_w, min_h - 1.0, min_d), minimum = Vector(min_w, min_h - 1.0, min_d),
maximum = Vector(max_w, max_h - self._raft_thickness - self._extra_z_clearance, max_d)) maximum = Vector(max_w, max_h - self._raft_thickness - self._extra_z_clearance, max_d))
bed_adhesion_size = self._getEdgeDisallowedSize() bed_adhesion_size = self.getEdgeDisallowedSize()
# As this works better for UM machines, we only add the disallowed_area_size for the z direction. # As this works better for UM machines, we only add the disallowed_area_size for the z direction.
# This is probably wrong in all other cases. TODO! # This is probably wrong in all other cases. TODO!
@ -465,7 +488,7 @@ class BuildVolume(SceneNode):
maximum = Vector(max_w - bed_adhesion_size - 1, max_h - self._raft_thickness - self._extra_z_clearance, max_d - disallowed_area_size + bed_adhesion_size - 1) maximum = Vector(max_w - bed_adhesion_size - 1, max_h - self._raft_thickness - self._extra_z_clearance, max_d - disallowed_area_size + bed_adhesion_size - 1)
) )
Application.getInstance().getController().getScene()._maximum_bounds = scale_to_max_bounds self._application.getController().getScene()._maximum_bounds = scale_to_max_bounds
self.updateNodeBoundaryCheck() self.updateNodeBoundaryCheck()
@ -477,6 +500,8 @@ class BuildVolume(SceneNode):
def _updateRaftThickness(self): def _updateRaftThickness(self):
old_raft_thickness = self._raft_thickness old_raft_thickness = self._raft_thickness
if self._global_container_stack.extruders:
# This might be called before the extruder stacks have initialised, in which case getting the adhesion_type fails
self._adhesion_type = self._global_container_stack.getProperty("adhesion_type", "value") self._adhesion_type = self._global_container_stack.getProperty("adhesion_type", "value")
self._raft_thickness = 0.0 self._raft_thickness = 0.0
if self._adhesion_type == "raft": if self._adhesion_type == "raft":
@ -510,19 +535,22 @@ class BuildVolume(SceneNode):
if extra_z != self._extra_z_clearance: if extra_z != self._extra_z_clearance:
self._extra_z_clearance = extra_z self._extra_z_clearance = extra_z
## Update the build volume visualization
def _onStackChanged(self): def _onStackChanged(self):
self._stack_change_timer.start()
## Update the build volume visualization
def _onStackChangeTimerFinished(self):
if self._global_container_stack: if self._global_container_stack:
self._global_container_stack.propertyChanged.disconnect(self._onSettingPropertyChanged) self._global_container_stack.propertyChanged.disconnect(self._onSettingPropertyChanged)
extruders = ExtruderManager.getInstance().getMachineExtruders(self._global_container_stack.getId()) extruders = ExtruderManager.getInstance().getActiveExtruderStacks()
for extruder in extruders: for extruder in extruders:
extruder.propertyChanged.disconnect(self._onSettingPropertyChanged) extruder.propertyChanged.disconnect(self._onSettingPropertyChanged)
self._global_container_stack = Application.getInstance().getGlobalContainerStack() self._global_container_stack = self._application.getGlobalContainerStack()
if self._global_container_stack: if self._global_container_stack:
self._global_container_stack.propertyChanged.connect(self._onSettingPropertyChanged) self._global_container_stack.propertyChanged.connect(self._onSettingPropertyChanged)
extruders = ExtruderManager.getInstance().getMachineExtruders(self._global_container_stack.getId()) extruders = ExtruderManager.getInstance().getActiveExtruderStacks()
for extruder in extruders: for extruder in extruders:
extruder.propertyChanged.connect(self._onSettingPropertyChanged) extruder.propertyChanged.connect(self._onSettingPropertyChanged)
@ -547,6 +575,12 @@ class BuildVolume(SceneNode):
if self._engine_ready: if self._engine_ready:
self.rebuild() self.rebuild()
camera = Application.getInstance().getController().getCameraTool()
if camera:
diagonal = self.getDiagonalSize()
if diagonal > 1:
camera.setZoomRange(min = 0.1, max = diagonal * 5) #You can zoom out up to 5 times the diagonal. This gives some space around the volume.
def _onEngineCreated(self): def _onEngineCreated(self):
self._engine_ready = True self._engine_ready = True
self.rebuild() self.rebuild()
@ -561,7 +595,7 @@ class BuildVolume(SceneNode):
if setting_key == "print_sequence": if setting_key == "print_sequence":
machine_height = self._global_container_stack.getProperty("machine_height", "value") machine_height = self._global_container_stack.getProperty("machine_height", "value")
if Application.getInstance().getGlobalContainerStack().getProperty("print_sequence", "value") == "one_at_a_time" and len(self._scene_objects) > 1: if self._application.getGlobalContainerStack().getProperty("print_sequence", "value") == "one_at_a_time" and len(self._scene_objects) > 1:
self._height = min(self._global_container_stack.getProperty("gantry_height", "value"), machine_height) self._height = min(self._global_container_stack.getProperty("gantry_height", "value"), machine_height)
if self._height < machine_height: if self._height < machine_height:
self._build_volume_message.show() self._build_volume_message.show()
@ -633,6 +667,7 @@ class BuildVolume(SceneNode):
# ``_updateDisallowedAreas`` method itself shouldn't call ``rebuild``, # ``_updateDisallowedAreas`` method itself shouldn't call ``rebuild``,
# since there may be other changes before it needs to be rebuilt, which # since there may be other changes before it needs to be rebuilt, which
# would hit performance. # would hit performance.
def _updateDisallowedAreasAndRebuild(self): def _updateDisallowedAreasAndRebuild(self):
self._updateDisallowedAreas() self._updateDisallowedAreas()
self._updateRaftThickness() self._updateRaftThickness()
@ -647,7 +682,7 @@ class BuildVolume(SceneNode):
extruder_manager = ExtruderManager.getInstance() extruder_manager = ExtruderManager.getInstance()
used_extruders = extruder_manager.getUsedExtruderStacks() used_extruders = extruder_manager.getUsedExtruderStacks()
disallowed_border_size = self._getEdgeDisallowedSize() disallowed_border_size = self.getEdgeDisallowedSize()
if not used_extruders: if not used_extruders:
# If no extruder is used, assume that the active extruder is used (else nothing is drawn) # If no extruder is used, assume that the active extruder is used (else nothing is drawn)
@ -658,7 +693,8 @@ class BuildVolume(SceneNode):
result_areas = self._computeDisallowedAreasStatic(disallowed_border_size, used_extruders) #Normal machine disallowed areas can always be added. result_areas = self._computeDisallowedAreasStatic(disallowed_border_size, used_extruders) #Normal machine disallowed areas can always be added.
prime_areas = self._computeDisallowedAreasPrimeBlob(disallowed_border_size, used_extruders) prime_areas = self._computeDisallowedAreasPrimeBlob(disallowed_border_size, used_extruders)
prime_disallowed_areas = self._computeDisallowedAreasStatic(0, used_extruders) #Where the priming is not allowed to happen. This is not added to the result, just for collision checking. result_areas_no_brim = self._computeDisallowedAreasStatic(0, used_extruders) #Where the priming is not allowed to happen. This is not added to the result, just for collision checking.
prime_disallowed_areas = copy.deepcopy(result_areas_no_brim)
#Check if prime positions intersect with disallowed areas. #Check if prime positions intersect with disallowed areas.
for extruder in used_extruders: for extruder in used_extruders:
@ -687,27 +723,37 @@ class BuildVolume(SceneNode):
break break
result_areas[extruder_id].extend(prime_areas[extruder_id]) result_areas[extruder_id].extend(prime_areas[extruder_id])
result_areas_no_brim[extruder_id].extend(prime_areas[extruder_id])
nozzle_disallowed_areas = extruder.getProperty("nozzle_disallowed_areas", "value") nozzle_disallowed_areas = extruder.getProperty("nozzle_disallowed_areas", "value")
for area in nozzle_disallowed_areas: for area in nozzle_disallowed_areas:
polygon = Polygon(numpy.array(area, numpy.float32)) polygon = Polygon(numpy.array(area, numpy.float32))
polygon = polygon.getMinkowskiHull(Polygon.approximatedCircle(disallowed_border_size)) polygon_disallowed_border = polygon.getMinkowskiHull(Polygon.approximatedCircle(disallowed_border_size))
result_areas[extruder_id].append(polygon) #Don't perform the offset on these. result_areas[extruder_id].append(polygon_disallowed_border) #Don't perform the offset on these.
#polygon_minimal_border = polygon.getMinkowskiHull(5)
result_areas_no_brim[extruder_id].append(polygon) # no brim
# Add prime tower location as disallowed area. # Add prime tower location as disallowed area.
if len(used_extruders) > 1: #No prime tower in single-extrusion. if len(used_extruders) > 1: #No prime tower in single-extrusion.
if len([x for x in used_extruders if x.isEnabled == True]) > 1: #No prime tower if only one extruder is enabled
prime_tower_collision = False prime_tower_collision = False
prime_tower_areas = self._computeDisallowedAreasPrinted(used_extruders) prime_tower_areas = self._computeDisallowedAreasPrinted(used_extruders)
for extruder_id in prime_tower_areas: for extruder_id in prime_tower_areas:
for prime_tower_area in prime_tower_areas[extruder_id]: for i_area, prime_tower_area in enumerate(prime_tower_areas[extruder_id]):
for area in result_areas[extruder_id]: for area in result_areas[extruder_id]:
if prime_tower_area.intersectsPolygon(area) is not None: if prime_tower_area.intersectsPolygon(area) is not None:
prime_tower_collision = True prime_tower_collision = True
break break
if prime_tower_collision: #Already found a collision. if prime_tower_collision: #Already found a collision.
break break
if (ExtruderManager.getInstance().getResolveOrValue("prime_tower_brim_enable") and
ExtruderManager.getInstance().getResolveOrValue("adhesion_type") != "raft"):
prime_tower_areas[extruder_id][i_area] = prime_tower_area.getMinkowskiHull(
Polygon.approximatedCircle(disallowed_border_size))
if not prime_tower_collision: if not prime_tower_collision:
result_areas[extruder_id].extend(prime_tower_areas[extruder_id]) result_areas[extruder_id].extend(prime_tower_areas[extruder_id])
result_areas_no_brim[extruder_id].extend(prime_tower_areas[extruder_id])
else: else:
self._error_areas.extend(prime_tower_areas[extruder_id]) self._error_areas.extend(prime_tower_areas[extruder_id])
@ -716,6 +762,9 @@ class BuildVolume(SceneNode):
self._disallowed_areas = [] self._disallowed_areas = []
for extruder_id in result_areas: for extruder_id in result_areas:
self._disallowed_areas.extend(result_areas[extruder_id]) self._disallowed_areas.extend(result_areas[extruder_id])
self._disallowed_areas_no_brim = []
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 ## Computes the disallowed areas for objects that are printed with print
# features. # features.
@ -741,6 +790,16 @@ class BuildVolume(SceneNode):
prime_tower_x = prime_tower_x - machine_width / 2 #Offset by half machine_width and _depth to put the origin in the front-left. prime_tower_x = prime_tower_x - machine_width / 2 #Offset by half machine_width and _depth to put the origin in the front-left.
prime_tower_y = prime_tower_y + machine_depth / 2 prime_tower_y = prime_tower_y + machine_depth / 2
if (ExtruderManager.getInstance().getResolveOrValue("prime_tower_brim_enable") and
ExtruderManager.getInstance().getResolveOrValue("adhesion_type") != "raft"):
brim_size = (
extruder.getProperty("brim_line_count", "value") *
extruder.getProperty("skirt_brim_line_width", "value") / 100.0 *
extruder.getProperty("initial_layer_line_width_factor", "value")
)
prime_tower_x -= brim_size
prime_tower_y += brim_size
if self._global_container_stack.getProperty("prime_tower_circular", "value"): if self._global_container_stack.getProperty("prime_tower_circular", "value"):
radius = prime_tower_size / 2 radius = prime_tower_size / 2
prime_tower_area = Polygon.approximatedCircle(radius) prime_tower_area = Polygon.approximatedCircle(radius)
@ -949,12 +1008,12 @@ class BuildVolume(SceneNode):
all_values[i] = 0 all_values[i] = 0
return all_values return all_values
## Convenience function to calculate the disallowed radius around the edge. ## Calculate the disallowed radius around the edge.
# #
# This disallowed radius is to allow for space around the models that is # 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) # not part of the collision radius, such as bed adhesion (skirt/brim/raft)
# and travel avoid distance. # and travel avoid distance.
def _getEdgeDisallowedSize(self): def getEdgeDisallowedSize(self):
if not self._global_container_stack or not self._global_container_stack.extruders: if not self._global_container_stack or not self._global_container_stack.extruders:
return 0 return 0
@ -980,7 +1039,9 @@ class BuildVolume(SceneNode):
# We don't create an additional line for the extruder we're printing the skirt with. # We don't create an additional line for the extruder we're printing the skirt with.
bed_adhesion_size -= skirt_brim_line_width * initial_layer_line_width_factor / 100.0 bed_adhesion_size -= skirt_brim_line_width * initial_layer_line_width_factor / 100.0
elif adhesion_type == "brim": elif (adhesion_type == "brim" or
(self._global_container_stack.getProperty("prime_tower_brim_enable", "value") and
self._global_container_stack.getProperty("adhesion_type", "value") != "raft")):
brim_line_count = self._global_container_stack.getProperty("brim_line_count", "value") brim_line_count = self._global_container_stack.getProperty("brim_line_count", "value")
bed_adhesion_size = skirt_brim_line_width * brim_line_count * initial_layer_line_width_factor / 100.0 bed_adhesion_size = skirt_brim_line_width * brim_line_count * initial_layer_line_width_factor / 100.0
@ -999,6 +1060,12 @@ class BuildVolume(SceneNode):
else: else:
raise Exception("Unknown bed adhesion type. Did you forget to update the build volume calculations for your new bed adhesion type?") raise Exception("Unknown bed adhesion type. Did you forget to update the build volume calculations for your new bed adhesion type?")
max_length_available = 0.5 * min(
self._global_container_stack.getProperty("machine_width", "value"),
self._global_container_stack.getProperty("machine_depth", "value")
)
bed_adhesion_size = min(bed_adhesion_size, max_length_available)
support_expansion = 0 support_expansion = 0
support_enabled = self._global_container_stack.getProperty("support_enable", "value") support_enabled = self._global_container_stack.getProperty("support_enable", "value")
support_offset = self._global_container_stack.getProperty("support_offset", "value") support_offset = self._global_container_stack.getProperty("support_offset", "value")
@ -1033,8 +1100,8 @@ class BuildVolume(SceneNode):
_raft_settings = ["adhesion_type", "raft_base_thickness", "raft_interface_thickness", "raft_surface_layers", "raft_surface_thickness", "raft_airgap", "layer_0_z_overlap"] _raft_settings = ["adhesion_type", "raft_base_thickness", "raft_interface_thickness", "raft_surface_layers", "raft_surface_thickness", "raft_airgap", "layer_0_z_overlap"]
_extra_z_settings = ["retraction_hop_enabled", "retraction_hop"] _extra_z_settings = ["retraction_hop_enabled", "retraction_hop"]
_prime_settings = ["extruder_prime_pos_x", "extruder_prime_pos_y", "extruder_prime_pos_z", "prime_blob_enable"] _prime_settings = ["extruder_prime_pos_x", "extruder_prime_pos_y", "extruder_prime_pos_z", "prime_blob_enable"]
_tower_settings = ["prime_tower_enable", "prime_tower_circular", "prime_tower_size", "prime_tower_position_x", "prime_tower_position_y"] _tower_settings = ["prime_tower_enable", "prime_tower_circular", "prime_tower_size", "prime_tower_position_x", "prime_tower_position_y", "prime_tower_brim_enable"]
_ooze_shield_settings = ["ooze_shield_enabled", "ooze_shield_dist"] _ooze_shield_settings = ["ooze_shield_enabled", "ooze_shield_dist"]
_distance_settings = ["infill_wipe_dist", "travel_avoid_distance", "support_offset", "support_enable", "travel_avoid_other_parts"] _distance_settings = ["infill_wipe_dist", "travel_avoid_distance", "support_offset", "support_enable", "travel_avoid_other_parts", "travel_avoid_supports"]
_extruder_settings = ["support_enable", "support_bottom_enable", "support_roof_enable", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "brim_line_count", "adhesion_extruder_nr", "adhesion_type"] #Settings that can affect which extruders are used. _extruder_settings = ["support_enable", "support_bottom_enable", "support_roof_enable", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "brim_line_count", "adhesion_extruder_nr", "adhesion_type"] #Settings that can affect which extruders are used.
_limit_to_extruder_settings = ["wall_extruder_nr", "wall_0_extruder_nr", "wall_x_extruder_nr", "top_bottom_extruder_nr", "infill_extruder_nr", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "adhesion_extruder_nr"] _limit_to_extruder_settings = ["wall_extruder_nr", "wall_0_extruder_nr", "wall_x_extruder_nr", "top_bottom_extruder_nr", "infill_extruder_nr", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_bottom_extruder_nr", "support_roof_extruder_nr", "adhesion_extruder_nr"]

View file

@ -1,18 +0,0 @@
from PyQt5.QtGui import QImage
from PyQt5.QtQuick import QQuickImageProvider
from PyQt5.QtCore import QSize
from UM.Application import Application
class CameraImageProvider(QQuickImageProvider):
def __init__(self):
QQuickImageProvider.__init__(self, QQuickImageProvider.Image)
## Request a new image.
def requestImage(self, id, size):
for output_device in Application.getInstance().getOutputDeviceManager().getOutputDevices():
try:
return output_device.activePrinter.camera.getImage(), QSize(15, 15)
except AttributeError:
pass
return QImage(), QSize(15, 15)

View file

@ -36,18 +36,14 @@ else:
except ImportError: except ImportError:
CuraDebugMode = False # [CodeStyle: Reflecting imported value] CuraDebugMode = False # [CodeStyle: Reflecting imported value]
# List of exceptions that should be considered "fatal" and abort the program. # List of exceptions that should not be considered "fatal" and abort the program.
# These are primarily some exception types that we simply cannot really recover from # These are primarily some exception types that we simply skip
# (MemoryError and SystemError) and exceptions that indicate grave errors in the skip_exception_types = [
# code that cause the Python interpreter to fail (SyntaxError, ImportError). SystemExit,
fatal_exception_types = [ KeyboardInterrupt,
MemoryError, GeneratorExit
SyntaxError,
ImportError,
SystemError,
] ]
class CrashHandler: class CrashHandler:
crash_url = "https://stats.ultimaker.com/api/cura" crash_url = "https://stats.ultimaker.com/api/cura"
@ -70,7 +66,7 @@ class CrashHandler:
# If Cura has fully started, we only show fatal errors. # If Cura has fully started, we only show fatal errors.
# If Cura has not fully started yet, we always show the early crash dialog. Otherwise, Cura will just crash # If Cura has not fully started yet, we always show the early crash dialog. Otherwise, Cura will just crash
# without any information. # without any information.
if has_started and exception_type not in fatal_exception_types: if has_started and exception_type in skip_exception_types:
return return
if not has_started: if not has_started:
@ -387,7 +383,7 @@ class CrashHandler:
Application.getInstance().callLater(self._show) Application.getInstance().callLater(self._show)
def _show(self): def _show(self):
# When the exception is not in the fatal_exception_types list, the dialog is not created, so we don't need to show it # When the exception is in the skip_exception_types list, the dialog is not created, so we don't need to show it
if self.dialog: if self.dialog:
self.dialog.exec_() self.dialog.exec_()
os._exit(1) os._exit(1)

View file

@ -1,20 +1,20 @@
# Copyright (c) 2017 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import QObject, QUrl from PyQt5.QtCore import QObject, QUrl
from PyQt5.QtGui import QDesktopServices from PyQt5.QtGui import QDesktopServices
from UM.FlameProfiler import pyqtSlot from typing import List, TYPE_CHECKING, cast
from UM.Event import CallFunctionEvent from UM.Event import CallFunctionEvent
from UM.Application import Application from UM.FlameProfiler import pyqtSlot
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
from UM.Scene.Selection import Selection from UM.Scene.Selection import Selection
from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator
from UM.Operations.GroupedOperation import GroupedOperation from UM.Operations.GroupedOperation import GroupedOperation
from UM.Operations.RemoveSceneNodeOperation import RemoveSceneNodeOperation from UM.Operations.RemoveSceneNodeOperation import RemoveSceneNodeOperation
from UM.Operations.SetTransformOperation import SetTransformOperation
from UM.Operations.TranslateOperation import TranslateOperation from UM.Operations.TranslateOperation import TranslateOperation
import cura.CuraApplication
from cura.Operations.SetParentOperation import SetParentOperation from cura.Operations.SetParentOperation import SetParentOperation
from cura.MultiplyObjectsJob import MultiplyObjectsJob from cura.MultiplyObjectsJob import MultiplyObjectsJob
from cura.Settings.SetObjectExtruderOperation import SetObjectExtruderOperation from cura.Settings.SetObjectExtruderOperation import SetObjectExtruderOperation
@ -24,30 +24,34 @@ from cura.Operations.SetBuildPlateNumberOperation import SetBuildPlateNumberOper
from UM.Logger import Logger from UM.Logger import Logger
if TYPE_CHECKING:
from UM.Scene.SceneNode import SceneNode
class CuraActions(QObject): class CuraActions(QObject):
def __init__(self, parent = None): def __init__(self, parent: QObject = None) -> None:
super().__init__(parent) super().__init__(parent)
@pyqtSlot() @pyqtSlot()
def openDocumentation(self): def openDocumentation(self) -> None:
# Starting a web browser from a signal handler connected to a menu will crash on windows. # Starting a web browser from a signal handler connected to a menu will crash on windows.
# So instead, defer the call to the next run of the event loop, since that does work. # So instead, defer the call to the next run of the event loop, since that does work.
# Note that weirdly enough, only signal handlers that open a web browser fail like that. # Note that weirdly enough, only signal handlers that open a web browser fail like that.
event = CallFunctionEvent(self._openUrl, [QUrl("http://ultimaker.com/en/support/software")], {}) event = CallFunctionEvent(self._openUrl, [QUrl("https://ultimaker.com/en/resources/manuals/software")], {})
Application.getInstance().functionEvent(event) cura.CuraApplication.CuraApplication.getInstance().functionEvent(event)
@pyqtSlot() @pyqtSlot()
def openBugReportPage(self): def openBugReportPage(self) -> None:
event = CallFunctionEvent(self._openUrl, [QUrl("http://github.com/Ultimaker/Cura/issues")], {}) event = CallFunctionEvent(self._openUrl, [QUrl("https://github.com/Ultimaker/Cura/issues")], {})
Application.getInstance().functionEvent(event) cura.CuraApplication.CuraApplication.getInstance().functionEvent(event)
## Reset camera position and direction to default ## Reset camera position and direction to default
@pyqtSlot() @pyqtSlot()
def homeCamera(self) -> None: def homeCamera(self) -> None:
scene = Application.getInstance().getController().getScene() scene = cura.CuraApplication.CuraApplication.getInstance().getController().getScene()
camera = scene.getActiveCamera() camera = scene.getActiveCamera()
camera.setPosition(Vector(-80, 250, 700)) if camera:
diagonal_size = cura.CuraApplication.CuraApplication.getInstance().getBuildVolume().getDiagonalSize()
camera.setPosition(Vector(-80, 250, 700) * diagonal_size / 375)
camera.setPerspective(True) camera.setPerspective(True)
camera.lookAt(Vector(0, 0, 0)) camera.lookAt(Vector(0, 0, 0))
@ -57,8 +61,10 @@ class CuraActions(QObject):
operation = GroupedOperation() operation = GroupedOperation()
for node in Selection.getAllSelectedObjects(): for node in Selection.getAllSelectedObjects():
current_node = node current_node = node
while current_node.getParent() and current_node.getParent().callDecoration("isGroup"): parent_node = current_node.getParent()
current_node = current_node.getParent() while parent_node and parent_node.callDecoration("isGroup"):
current_node = parent_node
parent_node = current_node.getParent()
# This was formerly done with SetTransformOperation but because of # This was formerly done with SetTransformOperation but because of
# unpredictable matrix deconstruction it was possible that mirrors # unpredictable matrix deconstruction it was possible that mirrors
@ -73,16 +79,17 @@ class CuraActions(QObject):
# \param count The number of times to multiply the selection. # \param count The number of times to multiply the selection.
@pyqtSlot(int) @pyqtSlot(int)
def multiplySelection(self, count: int) -> None: def multiplySelection(self, count: int) -> None:
job = MultiplyObjectsJob(Selection.getAllSelectedObjects(), count, min_offset = 8) 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() job.start()
## Delete all selected objects. ## Delete all selected objects.
@pyqtSlot() @pyqtSlot()
def deleteSelection(self) -> None: def deleteSelection(self) -> None:
if not Application.getInstance().getController().getToolsEnabled(): if not cura.CuraApplication.CuraApplication.getInstance().getController().getToolsEnabled():
return return
removed_group_nodes = [] removed_group_nodes = [] #type: List[SceneNode]
op = GroupedOperation() op = GroupedOperation()
nodes = Selection.getAllSelectedObjects() nodes = Selection.getAllSelectedObjects()
for node in nodes: for node in nodes:
@ -96,7 +103,7 @@ class CuraActions(QObject):
op.addOperation(RemoveSceneNodeOperation(group_node)) op.addOperation(RemoveSceneNodeOperation(group_node))
# Reset the print information # Reset the print information
Application.getInstance().getController().getScene().sceneChanged.emit(node) cura.CuraApplication.CuraApplication.getInstance().getController().getScene().sceneChanged.emit(node)
op.push() op.push()
@ -111,7 +118,7 @@ class CuraActions(QObject):
for node in Selection.getAllSelectedObjects(): for node in Selection.getAllSelectedObjects():
# If the node is a group, apply the active extruder to all children of the group. # If the node is a group, apply the active extruder to all children of the group.
if node.callDecoration("isGroup"): if node.callDecoration("isGroup"):
for grouped_node in BreadthFirstIterator(node): for grouped_node in BreadthFirstIterator(node): #type: ignore #Ignore type error because iter() should get called automatically by Python syntax.
if grouped_node.callDecoration("getActiveExtruder") == extruder_id: if grouped_node.callDecoration("getActiveExtruder") == extruder_id:
continue continue
@ -143,15 +150,15 @@ class CuraActions(QObject):
Logger.log("d", "Setting build plate number... %d" % build_plate_nr) Logger.log("d", "Setting build plate number... %d" % build_plate_nr)
operation = GroupedOperation() operation = GroupedOperation()
root = Application.getInstance().getController().getScene().getRoot() root = cura.CuraApplication.CuraApplication.getInstance().getController().getScene().getRoot()
nodes_to_change = [] nodes_to_change = [] # type: List[SceneNode]
for node in Selection.getAllSelectedObjects(): for node in Selection.getAllSelectedObjects():
parent_node = node # Find the parent node to change instead parent_node = node # Find the parent node to change instead
while parent_node.getParent() != root: while parent_node.getParent() != root:
parent_node = parent_node.getParent() parent_node = cast(SceneNode, parent_node.getParent())
for single_node in BreadthFirstIterator(parent_node): for single_node in BreadthFirstIterator(parent_node): # type: ignore #Ignore type error because iter() should get called automatically by Python syntax.
nodes_to_change.append(single_node) nodes_to_change.append(single_node)
if not nodes_to_change: if not nodes_to_change:
@ -164,5 +171,5 @@ class CuraActions(QObject):
Selection.clear() Selection.clear()
def _openUrl(self, url): def _openUrl(self, url: QUrl) -> None:
QDesktopServices.openUrl(url) QDesktopServices.openUrl(url)

File diff suppressed because it is too large Load diff

View file

@ -1,352 +1,41 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, Dict, Any from typing import List, Tuple
import json
import os
import shutil
import zipfile
import tempfile
from PyQt5.QtCore import pyqtSlot, QObject, pyqtSignal from cura.CuraApplication import CuraApplication #To find some resource types.
from cura.Settings.GlobalStack import GlobalStack
from UM.Application import Application from UM.PackageManager import PackageManager #The class we're extending.
from UM.Logger import Logger from UM.Resources import Resources #To find storage paths for some resource types.
from UM.Resources import Resources
from UM.Version import Version
class CuraPackageManager(QObject):
Version = 1
def __init__(self, parent = None): class CuraPackageManager(PackageManager):
super().__init__(parent) def __init__(self, application, parent = None):
super().__init__(application, parent)
self._application = Application.getInstance()
self._container_registry = self._application.getContainerRegistry()
self._plugin_registry = self._application.getPluginRegistry()
#JSON files that keep track of all installed packages.
self._user_package_management_file_path = None
self._bundled_package_management_file_path = None
for search_path in Resources.getSearchPaths():
candidate_bundled_path = os.path.join(search_path, "bundled_packages.json")
if os.path.exists(candidate_bundled_path):
self._bundled_package_management_file_path = candidate_bundled_path
for search_path in (Resources.getDataStoragePath(), Resources.getConfigStoragePath()):
candidate_user_path = os.path.join(search_path, "packages.json")
if os.path.exists(candidate_user_path):
self._user_package_management_file_path = candidate_user_path
if self._user_package_management_file_path is None: #Doesn't exist yet.
self._user_package_management_file_path = os.path.join(Resources.getDataStoragePath(), "packages.json")
self._bundled_package_dict = {} # A dict of all bundled packages
self._installed_package_dict = {} # A dict of all installed packages
self._to_remove_package_set = set() # A set of packages that need to be removed at the next start
self._to_install_package_dict = {} # A dict of packages that need to be installed at the next start
installedPackagesChanged = pyqtSignal() # Emitted whenever the installed packages collection have been changed.
def initialize(self): def initialize(self):
self._loadManagementData() self._installation_dirs_dict["materials"] = Resources.getStoragePath(CuraApplication.ResourceTypes.MaterialInstanceContainer)
self._removeAllScheduledPackages() self._installation_dirs_dict["qualities"] = Resources.getStoragePath(CuraApplication.ResourceTypes.QualityInstanceContainer)
self._installAllScheduledPackages()
# (for initialize) Loads the package management file if exists super().initialize()
def _loadManagementData(self) -> None:
# The bundles package management file should always be there
if not os.path.exists(self._bundled_package_management_file_path):
Logger.log("w", "Bundled package management file could not be found!")
return
# Load the bundled packages:
with open(self._bundled_package_management_file_path, "r", encoding = "utf-8") as f:
self._bundled_package_dict = json.load(f, encoding = "utf-8")
Logger.log("i", "Loaded bundled packages data from %s", self._bundled_package_management_file_path)
# Load the user package management file ## Returns a list of where the package is used
if not os.path.exists(self._user_package_management_file_path): # empty if it is never used.
Logger.log("i", "User package management file %s doesn't exist, do nothing", self._user_package_management_file_path) # It loops through all the package contents and see if some of the ids are used.
return # 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]]]:
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)]
machine_with_materials = []
machine_with_qualities = []
for container_id in ids:
for global_stack in global_stacks:
for extruder_nr, extruder_stack in global_stack.extruders.items():
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))
if container_id == extruder_stack.quality.getId():
machine_with_qualities.append((global_stack, extruder_nr, container_id))
# Need to use the file lock here to prevent concurrent I/O from other processes/threads return machine_with_materials, machine_with_qualities
container_registry = self._application.getContainerRegistry()
with container_registry.lockFile():
# Load the user packages:
with open(self._user_package_management_file_path, "r", encoding="utf-8") as f:
management_dict = json.load(f, encoding="utf-8")
self._installed_package_dict = management_dict.get("installed", {})
self._to_remove_package_set = set(management_dict.get("to_remove", []))
self._to_install_package_dict = management_dict.get("to_install", {})
Logger.log("i", "Loaded user packages management file from %s", self._user_package_management_file_path)
def _saveManagementData(self) -> None:
# Need to use the file lock here to prevent concurrent I/O from other processes/threads
container_registry = self._application.getContainerRegistry()
with container_registry.lockFile():
with open(self._user_package_management_file_path, "w", encoding = "utf-8") as f:
data_dict = {"version": CuraPackageManager.Version,
"installed": self._installed_package_dict,
"to_remove": list(self._to_remove_package_set),
"to_install": self._to_install_package_dict}
data_dict["to_remove"] = list(data_dict["to_remove"])
json.dump(data_dict, f, sort_keys = True, indent = 4)
Logger.log("i", "Package management file %s was saved", self._user_package_management_file_path)
# (for initialize) Removes all packages that have been scheduled to be removed.
def _removeAllScheduledPackages(self) -> None:
for package_id in self._to_remove_package_set:
self._purgePackage(package_id)
del self._installed_package_dict[package_id]
self._to_remove_package_set.clear()
self._saveManagementData()
# (for initialize) Installs all packages that have been scheduled to be installed.
def _installAllScheduledPackages(self) -> None:
while self._to_install_package_dict:
package_id, package_info = list(self._to_install_package_dict.items())[0]
self._installPackage(package_info)
self._installed_package_dict[package_id] = self._to_install_package_dict[package_id]
del self._to_install_package_dict[package_id]
self._saveManagementData()
# Checks the given package is installed. If so, return a dictionary that contains the package's information.
def getInstalledPackageInfo(self, package_id: str) -> Optional[dict]:
if package_id in self._to_remove_package_set:
return None
if package_id in self._to_install_package_dict:
package_info = self._to_install_package_dict[package_id]["package_info"]
return package_info
if package_id in self._installed_package_dict:
package_info = self._installed_package_dict[package_id]["package_info"]
return package_info
if package_id in self._bundled_package_dict:
package_info = self._bundled_package_dict[package_id]["package_info"]
return package_info
return None
def getAllInstalledPackagesInfo(self) -> dict:
# Add bundled, installed, and to-install packages to the set of installed package IDs
all_installed_ids = set()
if self._bundled_package_dict.keys():
all_installed_ids = all_installed_ids.union(set(self._bundled_package_dict.keys()))
if self._installed_package_dict.keys():
all_installed_ids = all_installed_ids.union(set(self._installed_package_dict.keys()))
all_installed_ids = all_installed_ids.difference(self._to_remove_package_set)
# If it's going to be installed and to be removed, then the package is being updated and it should be listed.
if self._to_install_package_dict.keys():
all_installed_ids = all_installed_ids.union(set(self._to_install_package_dict.keys()))
# map of <package_type> -> <package_id> -> <package_info>
installed_packages_dict = {}
for package_id in all_installed_ids:
# Skip required plugins as they should not be tampered with
if package_id in Application.getInstance().getRequiredPlugins():
continue
package_info = None
# Add bundled plugins
if package_id in self._bundled_package_dict:
package_info = self._bundled_package_dict[package_id]["package_info"]
# Add installed plugins
if package_id in self._installed_package_dict:
package_info = self._installed_package_dict[package_id]["package_info"]
# Add to install plugins
if package_id in self._to_install_package_dict:
package_info = self._to_install_package_dict[package_id]["package_info"]
if package_info is None:
continue
# We also need to get information from the plugin registry such as if a plugin is active
package_info["is_active"] = self._plugin_registry.isActivePlugin(package_id)
# If the package ID is in bundled, label it as such
package_info["is_bundled"] = package_info["package_id"] in self._bundled_package_dict.keys() and not self.isUserInstalledPackage(package_info["package_id"])
# If there is not a section in the dict for this type, add it
if package_info["package_type"] not in installed_packages_dict:
installed_packages_dict[package_info["package_type"]] = []
# Finally, add the data
installed_packages_dict[package_info["package_type"]].append(package_info)
return installed_packages_dict
# Checks if the given package is installed (at all).
def isPackageInstalled(self, package_id: str) -> bool:
return self.getInstalledPackageInfo(package_id) is not None
# Schedules the given package file to be installed upon the next start.
@pyqtSlot(str)
def installPackage(self, filename: str) -> None:
has_changes = False
try:
# Get package information
package_info = self.getPackageInfo(filename)
if not package_info:
return
package_id = package_info["package_id"]
# Check if it is installed
installed_package_info = self.getInstalledPackageInfo(package_info["package_id"])
to_install_package = installed_package_info is None # Install if the package has not been installed
if installed_package_info is not None:
# Compare versions and only schedule the installation if the given package is newer
new_version = package_info["package_version"]
installed_version = installed_package_info["package_version"]
if Version(new_version) > Version(installed_version):
Logger.log("i", "Package [%s] version [%s] is newer than the installed version [%s], update it.",
package_id, new_version, installed_version)
to_install_package = True
if to_install_package:
# Need to use the lock file to prevent concurrent I/O issues.
with self._container_registry.lockFile():
Logger.log("i", "Package [%s] version [%s] is scheduled to be installed.",
package_id, package_info["package_version"])
# Copy the file to cache dir so we don't need to rely on the original file to be present
package_cache_dir = os.path.join(os.path.abspath(Resources.getCacheStoragePath()), "cura_packages")
if not os.path.exists(package_cache_dir):
os.makedirs(package_cache_dir, exist_ok=True)
target_file_path = os.path.join(package_cache_dir, package_id + ".curapackage")
shutil.copy2(filename, target_file_path)
self._to_install_package_dict[package_id] = {"package_info": package_info,
"filename": target_file_path}
has_changes = True
except:
Logger.logException("c", "Failed to install package file '%s'", filename)
finally:
self._saveManagementData()
if has_changes:
self.installedPackagesChanged.emit()
# Schedules the given package to be removed upon the next start.
# \param package_id id of the package
# \param force_add is used when updating. In that case you actually want to uninstall & install
@pyqtSlot(str)
def removePackage(self, package_id: str, force_add: bool = False) -> None:
# Check the delayed installation and removal lists first
if not self.isPackageInstalled(package_id):
Logger.log("i", "Attempt to remove package [%s] that is not installed, do nothing.", package_id)
return
# Extra safety check
if package_id not in self._installed_package_dict and package_id in self._bundled_package_dict:
Logger.log("i", "Not uninstalling [%s] because it is a bundled package.")
return
if package_id not in self._to_install_package_dict or force_add:
# Schedule for a delayed removal:
self._to_remove_package_set.add(package_id)
else:
if package_id in self._to_install_package_dict:
# Remove from the delayed installation list if present
del self._to_install_package_dict[package_id]
self._saveManagementData()
self.installedPackagesChanged.emit()
## Is the package an user installed package?
def isUserInstalledPackage(self, package_id: str):
return package_id in self._installed_package_dict
# Removes everything associated with the given package ID.
def _purgePackage(self, package_id: str) -> None:
# Iterate through all directories in the data storage directory and look for sub-directories that belong to
# the package we need to remove, that is the sub-dirs with the package_id as names, and remove all those dirs.
data_storage_dir = os.path.abspath(Resources.getDataStoragePath())
for root, dir_names, _ in os.walk(data_storage_dir):
for dir_name in dir_names:
package_dir = os.path.join(root, dir_name, package_id)
if os.path.exists(package_dir):
Logger.log("i", "Removing '%s' for package [%s]", package_dir, package_id)
shutil.rmtree(package_dir)
break
# Installs all files associated with the given package.
def _installPackage(self, installation_package_data: dict):
package_info = installation_package_data["package_info"]
filename = installation_package_data["filename"]
package_id = package_info["package_id"]
if not os.path.exists(filename):
Logger.log("w", "Package [%s] file '%s' is missing, cannot install this package", package_id, filename)
return
Logger.log("i", "Installing package [%s] from file [%s]", package_id, filename)
# If it's installed, remove it first and then install
if package_id in self._installed_package_dict:
self._purgePackage(package_id)
# Install the package
with zipfile.ZipFile(filename, "r") as archive:
temp_dir = tempfile.TemporaryDirectory()
archive.extractall(temp_dir.name)
from cura.CuraApplication import CuraApplication
installation_dirs_dict = {
"materials": Resources.getStoragePath(CuraApplication.ResourceTypes.MaterialInstanceContainer),
"qualities": Resources.getStoragePath(CuraApplication.ResourceTypes.QualityInstanceContainer),
"plugins": os.path.abspath(Resources.getStoragePath(Resources.Plugins)),
}
for sub_dir_name, installation_root_dir in installation_dirs_dict.items():
src_dir_path = os.path.join(temp_dir.name, "files", sub_dir_name)
dst_dir_path = os.path.join(installation_root_dir, package_id)
if not os.path.exists(src_dir_path):
continue
self.__installPackageFiles(package_id, src_dir_path, dst_dir_path)
# Remove the file
os.remove(filename)
def __installPackageFiles(self, package_id: str, src_dir: str, dst_dir: str) -> None:
Logger.log("i", "Moving package {package_id} from {src_dir} to {dst_dir}".format(package_id=package_id, src_dir=src_dir, dst_dir=dst_dir))
shutil.move(src_dir, dst_dir)
# Gets package information from the given file.
def getPackageInfo(self, filename: str) -> Dict[str, Any]:
with zipfile.ZipFile(filename) as archive:
try:
# All information is in package.json
with archive.open("package.json") as f:
package_info_dict = json.loads(f.read().decode("utf-8"))
return package_info_dict
except Exception as e:
Logger.logException("w", "Could not get package information from file '%s': %s" % (filename, e))
return {}
# Gets the license file content if present in the given package file.
# Returns None if there is no license file found.
def getPackageLicense(self, filename: str) -> Optional[str]:
license_string = None
with zipfile.ZipFile(filename) as archive:
# Go through all the files and use the first successful read as the result
for file_info in archive.infolist():
if file_info.filename.endswith("LICENSE"):
Logger.log("d", "Found potential license file '%s'", file_info.filename)
try:
with archive.open(file_info.filename, "r") as f:
data = f.read()
license_string = data.decode("utf-8")
break
except:
Logger.logException("e", "Failed to load potential license file '%s' as text file.",
file_info.filename)
license_string = None
return license_string

View file

@ -1,6 +1,12 @@
# Copyright (c) 2015 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
CuraAppName = "@CURA_APP_NAME@"
CuraAppDisplayName = "@CURA_APP_DISPLAY_NAME@"
CuraVersion = "@CURA_VERSION@" CuraVersion = "@CURA_VERSION@"
CuraBuildType = "@CURA_BUILDTYPE@" CuraBuildType = "@CURA_BUILDTYPE@"
CuraDebugMode = True if "@_cura_debugmode@" == "ON" else False CuraDebugMode = True if "@_cura_debugmode@" == "ON" else False
CuraSDKVersion = "@CURA_SDK_VERSION@"
CuraCloudAPIRoot = "@CURA_CLOUD_API_ROOT@"
CuraCloudAPIVersion = "@CURA_CLOUD_API_VERSION@"
CuraCloudAccountAPIRoot = "@CURA_CLOUD_ACCOUNT_API_ROOT@"

24
cura/CuraView.py Normal file
View file

@ -0,0 +1,24 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty, QUrl
from UM.View.View import View
# Since Cura has a few pre-defined "space claims" for the locations of certain components, we've provided some structure
# to indicate this.
# MainComponent works in the same way the MainComponent of a stage.
# the stageMenuComponent returns an item that should be used somehwere in the stage menu. It's up to the active stage
# to actually do something with this.
class CuraView(View):
def __init__(self, parent = None) -> None:
super().__init__(parent)
@pyqtProperty(QUrl, constant = True)
def mainComponent(self) -> QUrl:
return self.getDisplayComponent("main")
@pyqtProperty(QUrl, constant = True)
def stageMenuComponent(self) -> QUrl:
return self.getDisplayComponent("menu")

View file

@ -7,43 +7,36 @@ from UM.Mesh.MeshBuilder import MeshBuilder
from .LayerData import LayerData from .LayerData import LayerData
import numpy import numpy
from typing import Dict, Optional
## Builder class for constructing a LayerData object ## Builder class for constructing a LayerData object
class LayerDataBuilder(MeshBuilder): class LayerDataBuilder(MeshBuilder):
def __init__(self): def __init__(self) -> None:
super().__init__() super().__init__()
self._layers = {} self._layers = {} # type: Dict[int, Layer]
self._element_counts = {} self._element_counts = {} # type: Dict[int, int]
def addLayer(self, layer): def addLayer(self, layer: int) -> None:
if layer not in self._layers: if layer not in self._layers:
self._layers[layer] = Layer(layer) self._layers[layer] = Layer(layer)
def addPolygon(self, layer, polygon_type, data, line_width, line_thickness, line_feedrate): def getLayer(self, layer: int) -> Optional[Layer]:
if layer not in self._layers: return self._layers.get(layer)
self.addLayer(layer)
p = LayerPolygon(self, polygon_type, data, line_width, line_thickness, line_feedrate) def getLayers(self) -> Dict[int, Layer]:
self._layers[layer].polygons.append(p)
def getLayer(self, layer):
if layer in self._layers:
return self._layers[layer]
def getLayers(self):
return self._layers return self._layers
def getElementCounts(self): def getElementCounts(self) -> Dict[int, int]:
return self._element_counts return self._element_counts
def setLayerHeight(self, layer, height): def setLayerHeight(self, layer: int, height: float) -> None:
if layer not in self._layers: if layer not in self._layers:
self.addLayer(layer) self.addLayer(layer)
self._layers[layer].setHeight(height) self._layers[layer].setHeight(height)
def setLayerThickness(self, layer, thickness): def setLayerThickness(self, layer: int, thickness: float) -> None:
if layer not in self._layers: if layer not in self._layers:
self.addLayer(layer) self.addLayer(layer)
@ -71,7 +64,7 @@ class LayerDataBuilder(MeshBuilder):
vertex_offset = 0 vertex_offset = 0
index_offset = 0 index_offset = 0
for layer, data in sorted(self._layers.items()): for layer, data in sorted(self._layers.items()):
( vertex_offset, index_offset ) = data.build( vertex_offset, index_offset, vertices, colors, line_dimensions, feedrates, extruders, line_types, indices) vertex_offset, index_offset = data.build(vertex_offset, index_offset, vertices, colors, line_dimensions, feedrates, extruders, line_types, indices)
self._element_counts[layer] = data.elementCount self._element_counts[layer] = data.elementCount
self.addVertices(vertices) self.addVertices(vertices)

View file

@ -1,13 +1,25 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
from cura.LayerData import LayerData
## Simple decorator to indicate a scene node holds layer data. ## Simple decorator to indicate a scene node holds layer data.
class LayerDataDecorator(SceneNodeDecorator): class LayerDataDecorator(SceneNodeDecorator):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._layer_data = None self._layer_data = None # type: Optional[LayerData]
def getLayerData(self): def getLayerData(self) -> Optional["LayerData"]:
return self._layer_data return self._layer_data
def setLayerData(self, layer_data): def setLayerData(self, layer_data: LayerData) -> None:
self._layer_data = layer_data self._layer_data = layer_data
def __deepcopy__(self, memo) -> "LayerDataDecorator":
copied_decorator = LayerDataDecorator()
copied_decorator._layer_data = self._layer_data
return copied_decorator

View file

@ -2,9 +2,11 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from UM.Application import Application from UM.Application import Application
from typing import Any from typing import Any, Optional
import numpy import numpy
from UM.Logger import Logger
class LayerPolygon: class LayerPolygon:
NoneType = 0 NoneType = 0
@ -18,22 +20,24 @@ class LayerPolygon:
MoveCombingType = 8 MoveCombingType = 8
MoveRetractionType = 9 MoveRetractionType = 9
SupportInterfaceType = 10 SupportInterfaceType = 10
__number_of_types = 11 PrimeTower = 11
__number_of_types = 12
__jump_map = numpy.logical_or(numpy.logical_or(numpy.arange(__number_of_types) == NoneType, numpy.arange(__number_of_types) == MoveCombingType), numpy.arange(__number_of_types) == MoveRetractionType) __jump_map = numpy.logical_or(numpy.logical_or(numpy.arange(__number_of_types) == NoneType, numpy.arange(__number_of_types) == MoveCombingType), numpy.arange(__number_of_types) == MoveRetractionType)
## LayerPolygon, used in ProcessSlicedLayersJob ## LayerPolygon, used in ProcessSlicedLayersJob
# \param extruder # \param extruder The position of the extruder
# \param line_types array with line_types # \param line_types array with line_types
# \param data new_points # \param data new_points
# \param line_widths array with line widths # \param line_widths array with line widths
# \param line_thicknesses: array with type as index and thickness as value # \param line_thicknesses: array with type as index and thickness as value
# \param line_feedrates array with line feedrates # \param line_feedrates array with line feedrates
def __init__(self, extruder, line_types, data, line_widths, line_thicknesses, 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:
self._extruder = extruder self._extruder = extruder
self._types = line_types self._types = line_types
for i in range(len(self._types)): for i in range(len(self._types)):
if self._types[i] >= self.__number_of_types: #Got faulty line data from the engine. if self._types[i] >= self.__number_of_types: # Got faulty line data from the engine.
Logger.log("w", "Found an unknown line type: %s", i)
self._types[i] = self.NoneType self._types[i] = self.NoneType
self._data = data self._data = data
self._line_widths = line_widths self._line_widths = line_widths
@ -53,16 +57,16 @@ class LayerPolygon:
# Buffering the colors shouldn't be necessary as it is not # Buffering the colors shouldn't be necessary as it is not
# re-used and can save alot of memory usage. # re-used and can save alot of memory usage.
self._color_map = LayerPolygon.getColorMap() self._color_map = LayerPolygon.getColorMap()
self._colors = self._color_map[self._types] 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 # When type is used as index returns true if type == LayerPolygon.InfillType or type == LayerPolygon.SkinType or type == LayerPolygon.SupportInfillType
# Should be generated in better way, not hardcoded. # Should be generated in better way, not hardcoded.
self._isInfillOrSkinTypeMap = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1], dtype=numpy.bool) self._isInfillOrSkinTypeMap = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1], dtype=numpy.bool)
self._build_cache_line_mesh_mask = None self._build_cache_line_mesh_mask = None # type: Optional[numpy.ndarray]
self._build_cache_needed_points = None self._build_cache_needed_points = None # type: Optional[numpy.ndarray]
def buildCache(self): def buildCache(self) -> None:
# For the line mesh we do not draw Infill or Jumps. Therefore those lines are filtered out. # For the line mesh we do not draw Infill or Jumps. Therefore those lines are filtered out.
self._build_cache_line_mesh_mask = numpy.ones(self._jump_mask.shape, dtype=bool) self._build_cache_line_mesh_mask = numpy.ones(self._jump_mask.shape, dtype=bool)
mesh_line_count = numpy.sum(self._build_cache_line_mesh_mask) mesh_line_count = numpy.sum(self._build_cache_line_mesh_mask)
@ -90,10 +94,14 @@ class LayerPolygon:
# \param extruders : vertex numpy array to be filled # \param extruders : vertex numpy array to be filled
# \param line_types : vertex numpy array to be filled # \param line_types : vertex numpy array to be filled
# \param indices : index numpy array to be filled # \param indices : index numpy array to be filled
def build(self, vertex_offset, index_offset, vertices, colors, line_dimensions, feedrates, extruders, line_types, indices): 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:
if self._build_cache_line_mesh_mask is None or self._build_cache_needed_points is None: if self._build_cache_line_mesh_mask is None or self._build_cache_needed_points is None:
self.buildCache() self.buildCache()
if self._build_cache_line_mesh_mask is None or self._build_cache_needed_points is None:
Logger.log("w", "Failed to build cache for layer polygon")
return
line_mesh_mask = self._build_cache_line_mesh_mask line_mesh_mask = self._build_cache_line_mesh_mask
needed_points_list = self._build_cache_needed_points needed_points_list = self._build_cache_needed_points
@ -236,7 +244,8 @@ class LayerPolygon:
theme.getColor("layerview_support_infill").getRgbF(), # SupportInfillType theme.getColor("layerview_support_infill").getRgbF(), # SupportInfillType
theme.getColor("layerview_move_combing").getRgbF(), # MoveCombingType theme.getColor("layerview_move_combing").getRgbF(), # MoveCombingType
theme.getColor("layerview_move_retraction").getRgbF(), # MoveRetractionType theme.getColor("layerview_move_retraction").getRgbF(), # MoveRetractionType
theme.getColor("layerview_support_interface").getRgbF() # SupportInterfaceType theme.getColor("layerview_support_interface").getRgbF(), # SupportInterfaceType
theme.getColor("layerview_prime_tower").getRgbF()
]) ])
return cls.__color_map return cls.__color_map

View file

@ -1,13 +1,13 @@
# Copyright (c) 2016 Ultimaker B.V. # Copyright (c) 2016 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import os
from PyQt5.QtCore import QObject, pyqtSlot, pyqtProperty, pyqtSignal from PyQt5.QtCore import QObject, pyqtSlot, pyqtProperty, pyqtSignal
from UM.Logger import Logger
from UM.PluginObject import PluginObject from UM.PluginObject import PluginObject
from UM.PluginRegistry import PluginRegistry from UM.PluginRegistry import PluginRegistry
from UM.Application import Application
import os
## Machine actions are actions that are added to a specific machine type. Examples of such actions are ## Machine actions are actions that are added to a specific machine type. Examples of such actions are
@ -19,7 +19,7 @@ class MachineAction(QObject, PluginObject):
## Create a new Machine action. ## Create a new Machine action.
# \param key unique key of the machine action # \param key unique key of the machine action
# \param label Human readable label used to identify the machine action. # \param label Human readable label used to identify the machine action.
def __init__(self, key, label = ""): def __init__(self, key: str, label: str = "") -> None:
super().__init__() super().__init__()
self._key = key self._key = key
self._label = label self._label = label
@ -30,14 +30,20 @@ class MachineAction(QObject, PluginObject):
labelChanged = pyqtSignal() labelChanged = pyqtSignal()
onFinished = pyqtSignal() onFinished = pyqtSignal()
def getKey(self): def getKey(self) -> str:
return self._key return self._key
## Whether this action needs to ask the user anything.
# If not, we shouldn't present the user with certain screens which otherwise show up.
# Defaults to true to be in line with the old behaviour.
def needsUserInteraction(self) -> bool:
return True
@pyqtProperty(str, notify = labelChanged) @pyqtProperty(str, notify = labelChanged)
def label(self): def label(self) -> str:
return self._label return self._label
def setLabel(self, label): def setLabel(self, label: str) -> None:
if self._label != label: if self._label != label:
self._label = label self._label = label
self.labelChanged.emit() self.labelChanged.emit()
@ -46,29 +52,35 @@ class MachineAction(QObject, PluginObject):
# This should not be re-implemented by child classes, instead re-implement _reset. # This should not be re-implemented by child classes, instead re-implement _reset.
# /sa _reset # /sa _reset
@pyqtSlot() @pyqtSlot()
def reset(self): def reset(self) -> None:
self._finished = False self._finished = False
self._reset() self._reset()
## Protected implementation of reset. ## Protected implementation of reset.
# /sa reset() # /sa reset()
def _reset(self): def _reset(self) -> None:
pass pass
@pyqtSlot() @pyqtSlot()
def setFinished(self): def setFinished(self) -> None:
self._finished = True self._finished = True
self._reset() self._reset()
self.onFinished.emit() self.onFinished.emit()
@pyqtProperty(bool, notify = onFinished) @pyqtProperty(bool, notify = onFinished)
def finished(self): def finished(self) -> bool:
return self._finished return self._finished
## Protected helper to create a view object based on provided QML. ## Protected helper to create a view object based on provided QML.
def _createViewFromQML(self): def _createViewFromQML(self) -> None:
path = os.path.join(PluginRegistry.getInstance().getPluginPath(self.getPluginId()), self._qml_url) plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId())
self._view = Application.getInstance().createQmlComponent(path, {"manager": self}) if plugin_path is None:
Logger.log("e", "Cannot create QML view: cannot find plugin path for plugin [%s]", self.getPluginId())
return
path = os.path.join(plugin_path, self._qml_url)
from cura.CuraApplication import CuraApplication
self._view = CuraApplication.getInstance().createQmlComponent(path, {"manager": self})
@pyqtProperty(QObject, constant = True) @pyqtProperty(QObject, constant = True)
def displayItem(self): def displayItem(self):

View file

@ -1,7 +1,7 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional from typing import Optional, Any, Dict, Union, TYPE_CHECKING
from collections import OrderedDict from collections import OrderedDict
@ -21,23 +21,34 @@ from UM.Settings.InstanceContainer import InstanceContainer
# This is used in Variant, Material, and Quality Managers. # This is used in Variant, Material, and Quality Managers.
# #
class ContainerNode: class ContainerNode:
__slots__ = ("metadata", "container", "children_map") __slots__ = ("_metadata", "_container", "children_map")
def __init__(self, metadata: Optional[dict] = None): def __init__(self, metadata: Optional[Dict[str, Any]] = None) -> None:
self.metadata = metadata self._metadata = metadata
self.container = None self._container = None # type: Optional[InstanceContainer]
self.children_map = OrderedDict() self.children_map = OrderedDict() # type: ignore # This is because it's children are supposed to override it.
## Get an entry value from the metadata
def getMetaDataEntry(self, entry: str, default: Any = None) -> Any:
if self._metadata is None:
return default
return self._metadata.get(entry, default)
def getMetadata(self) -> Dict[str, Any]:
if self._metadata is None:
return {}
return self._metadata
def getChildNode(self, child_key: str) -> Optional["ContainerNode"]: def getChildNode(self, child_key: str) -> Optional["ContainerNode"]:
return self.children_map.get(child_key) return self.children_map.get(child_key)
def getContainer(self) -> Optional["InstanceContainer"]: def getContainer(self) -> Optional["InstanceContainer"]:
if self.metadata is None: if self._metadata is None:
Logger.log("e", "Cannot get container for a ContainerNode without metadata.") Logger.log("e", "Cannot get container for a ContainerNode without metadata.")
return None return None
if self.container is None: if self._container is None:
container_id = self.metadata["id"] container_id = self._metadata["id"]
from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.ContainerRegistry import ContainerRegistry
container_list = ContainerRegistry.getInstance().findInstanceContainers(id = container_id) container_list = ContainerRegistry.getInstance().findInstanceContainers(id = container_id)
if not container_list: if not container_list:
@ -45,9 +56,9 @@ class ContainerNode:
error_message = ConfigurationErrorMessage.getInstance() error_message = ConfigurationErrorMessage.getInstance()
error_message.addFaultyContainers(container_id) error_message.addFaultyContainers(container_id)
return None return None
self.container = container_list[0] self._container = container_list[0]
return self.container return self._container
def __str__(self) -> str: def __str__(self) -> str:
return "%s[%s]" % (self.__class__.__name__, self.metadata.get("id")) return "%s[%s]" % (self.__class__.__name__, self.getMetaDataEntry("id"))

View file

@ -50,7 +50,7 @@ class MachineErrorChecker(QObject):
self._error_check_timer.setInterval(100) self._error_check_timer.setInterval(100)
self._error_check_timer.setSingleShot(True) self._error_check_timer.setSingleShot(True)
def initialize(self): def initialize(self) -> None:
self._error_check_timer.timeout.connect(self._rescheduleCheck) self._error_check_timer.timeout.connect(self._rescheduleCheck)
# Reconnect all signals when the active machine gets changed. # Reconnect all signals when the active machine gets changed.
@ -62,23 +62,23 @@ class MachineErrorChecker(QObject):
self._onMachineChanged() self._onMachineChanged()
def _onMachineChanged(self): def _onMachineChanged(self) -> None:
if self._global_stack: if self._global_stack:
self._global_stack.propertyChanged.disconnect(self.startErrorCheck) self._global_stack.propertyChanged.disconnect(self.startErrorCheckPropertyChanged)
self._global_stack.containersChanged.disconnect(self.startErrorCheck) self._global_stack.containersChanged.disconnect(self.startErrorCheck)
for extruder in self._global_stack.extruders.values(): for extruder in self._global_stack.extruders.values():
extruder.propertyChanged.disconnect(self.startErrorCheck) extruder.propertyChanged.disconnect(self.startErrorCheckPropertyChanged)
extruder.containersChanged.disconnect(self.startErrorCheck) extruder.containersChanged.disconnect(self.startErrorCheck)
self._global_stack = self._machine_manager.activeMachine self._global_stack = self._machine_manager.activeMachine
if self._global_stack: if self._global_stack:
self._global_stack.propertyChanged.connect(self.startErrorCheck) self._global_stack.propertyChanged.connect(self.startErrorCheckPropertyChanged)
self._global_stack.containersChanged.connect(self.startErrorCheck) self._global_stack.containersChanged.connect(self.startErrorCheck)
for extruder in self._global_stack.extruders.values(): for extruder in self._global_stack.extruders.values():
extruder.propertyChanged.connect(self.startErrorCheck) extruder.propertyChanged.connect(self.startErrorCheckPropertyChanged)
extruder.containersChanged.connect(self.startErrorCheck) extruder.containersChanged.connect(self.startErrorCheck)
hasErrorUpdated = pyqtSignal() hasErrorUpdated = pyqtSignal()
@ -93,8 +93,15 @@ class MachineErrorChecker(QObject):
def needToWaitForResult(self) -> bool: def needToWaitForResult(self) -> bool:
return self._need_to_check or self._check_in_progress return self._need_to_check or self._check_in_progress
# Start the error check for property changed
# this is seperate from the startErrorCheck because it ignores a number property types
def startErrorCheckPropertyChanged(self, key, property_name):
if property_name != "value":
return
self.startErrorCheck()
# Starts the error check timer to schedule a new error check. # Starts the error check timer to schedule a new error check.
def startErrorCheck(self, *args): def startErrorCheck(self, *args) -> None:
if not self._check_in_progress: if not self._check_in_progress:
self._need_to_check = True self._need_to_check = True
self.needToWaitForResultChanged.emit() self.needToWaitForResultChanged.emit()
@ -103,7 +110,7 @@ class MachineErrorChecker(QObject):
# This function is called by the timer to reschedule a new error check. # 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 # 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. # to notify the current check to stop and start a new one.
def _rescheduleCheck(self): def _rescheduleCheck(self) -> None:
if self._check_in_progress and not self._need_to_check: if self._check_in_progress and not self._need_to_check:
self._need_to_check = True self._need_to_check = True
self.needToWaitForResultChanged.emit() self.needToWaitForResultChanged.emit()
@ -128,7 +135,7 @@ class MachineErrorChecker(QObject):
self._start_time = time.time() self._start_time = time.time()
Logger.log("d", "New error check scheduled.") Logger.log("d", "New error check scheduled.")
def _checkStack(self): def _checkStack(self) -> None:
if self._need_to_check: if self._need_to_check:
Logger.log("d", "Need to check for errors again. Discard the current progress and reschedule a check.") Logger.log("d", "Need to check for errors again. Discard the current progress and reschedule a check.")
self._check_in_progress = False self._check_in_progress = False
@ -169,7 +176,7 @@ class MachineErrorChecker(QObject):
# Schedule the check for the next key # Schedule the check for the next key
self._application.callLater(self._checkStack) self._application.callLater(self._checkStack)
def _setResult(self, result: bool): def _setResult(self, result: bool) -> None:
if result != self._has_errors: if result != self._has_errors:
self._has_errors = result self._has_errors = result
self.hasErrorUpdated.emit() self.hasErrorUpdated.emit()

View file

@ -1,8 +1,11 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import List from typing import List, TYPE_CHECKING
from cura.Machines.MaterialNode import MaterialNode #For type checking.
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. ## 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 # The main InstanceContainer which has the ID of the material profile file name is called the "root_material". For
@ -18,11 +21,11 @@ from cura.Machines.MaterialNode import MaterialNode #For type checking.
class MaterialGroup: class MaterialGroup:
__slots__ = ("name", "is_read_only", "root_material_node", "derived_material_node_list") __slots__ = ("name", "is_read_only", "root_material_node", "derived_material_node_list")
def __init__(self, name: str, root_material_node: MaterialNode): def __init__(self, name: str, root_material_node: "MaterialNode") -> None:
self.name = name self.name = name
self.is_read_only = False self.is_read_only = False
self.root_material_node = root_material_node self.root_material_node = root_material_node # type: MaterialNode
self.derived_material_node_list = [] #type: List[MaterialNode] self.derived_material_node_list = [] # type: List[MaterialNode]
def __str__(self) -> str: def __str__(self) -> str:
return "%s[%s]" % (self.__class__.__name__, self.name) return "%s[%s]" % (self.__class__.__name__, self.name)

View file

@ -4,7 +4,7 @@
from collections import defaultdict, OrderedDict from collections import defaultdict, OrderedDict
import copy import copy
import uuid import uuid
from typing import Optional, TYPE_CHECKING from typing import Dict, Optional, TYPE_CHECKING, Any, Set, List, cast, Tuple
from PyQt5.Qt import QTimer, QObject, pyqtSignal, pyqtSlot from PyQt5.Qt import QTimer, QObject, pyqtSignal, pyqtSlot
@ -17,6 +17,7 @@ from UM.Util import parseBool
from .MaterialNode import MaterialNode from .MaterialNode import MaterialNode
from .MaterialGroup import MaterialGroup from .MaterialGroup import MaterialGroup
from .VariantType import VariantType
if TYPE_CHECKING: if TYPE_CHECKING:
from UM.Settings.DefinitionContainer import DefinitionContainer from UM.Settings.DefinitionContainer import DefinitionContainer
@ -38,24 +39,34 @@ if TYPE_CHECKING:
class MaterialManager(QObject): class MaterialManager(QObject):
materialsUpdated = pyqtSignal() # Emitted whenever the material lookup tables are updated. materialsUpdated = pyqtSignal() # Emitted whenever the material lookup tables are updated.
favoritesUpdated = pyqtSignal() # Emitted whenever the favorites are changed
def __init__(self, container_registry, parent = None): def __init__(self, container_registry, parent = None):
super().__init__(parent) super().__init__(parent)
self._application = Application.getInstance() self._application = Application.getInstance()
self._container_registry = container_registry # type: ContainerRegistry self._container_registry = container_registry # type: ContainerRegistry
self._fallback_materials_map = dict() # material_type -> generic material metadata # Material_type -> generic material metadata
self._material_group_map = dict() # root_material_id -> MaterialGroup self._fallback_materials_map = dict() # type: Dict[str, Dict[str, Any]]
self._diameter_machine_variant_material_map = dict() # approximate diameter str -> dict(machine_definition_id -> MaterialNode)
# Root_material_id -> MaterialGroup
self._material_group_map = dict() # type: Dict[str, MaterialGroup]
# Approximate diameter str
self._diameter_machine_nozzle_buildplate_material_map = dict() # type: Dict[str, Dict[str, MaterialNode]]
# We're using these two maps to convert between the specific diameter material id and the generic material id # We're using these two maps to convert between the specific diameter material id and the generic material id
# because the generic material ids are used in qualities and definitions, while the specific diameter material is meant # because the generic material ids are used in qualities and definitions, while the specific diameter material is meant
# i.e. generic_pla -> generic_pla_175 # i.e. generic_pla -> generic_pla_175
self._material_diameter_map = defaultdict(dict) # root_material_id -> approximate diameter str -> root_material_id for that diameter # root_material_id -> approximate diameter str -> root_material_id for that diameter
self._diameter_material_map = dict() # material id including diameter (generic_pla_175) -> material root id (generic_pla) self._material_diameter_map = defaultdict(dict) # type: Dict[str, Dict[str, str]]
# Material id including diameter (generic_pla_175) -> material root id (generic_pla)
self._diameter_material_map = dict() # type: Dict[str, str]
# This is used in Legacy UM3 send material function and the material management page. # This is used in Legacy UM3 send material function and the material management page.
self._guid_material_groups_map = defaultdict(list) # GUID -> a list of material_groups # GUID -> a list of material_groups
self._guid_material_groups_map = defaultdict(list) # type: Dict[str, List[MaterialGroup]]
# The machine definition ID for the non-machine-specific materials. # The machine definition ID for the non-machine-specific materials.
# This is used as the last fallback option if the given machine-specific material(s) cannot be found. # This is used as the last fallback option if the given machine-specific material(s) cannot be found.
@ -74,13 +85,15 @@ class MaterialManager(QObject):
self._container_registry.containerAdded.connect(self._onContainerMetadataChanged) self._container_registry.containerAdded.connect(self._onContainerMetadataChanged)
self._container_registry.containerRemoved.connect(self._onContainerMetadataChanged) self._container_registry.containerRemoved.connect(self._onContainerMetadataChanged)
def initialize(self): self._favorites = set() # type: Set[str]
def initialize(self) -> None:
# Find all materials and put them in a matrix for quick search. # Find all materials and put them in a matrix for quick search.
material_metadatas = {metadata["id"]: metadata for metadata in material_metadatas = {metadata["id"]: metadata for metadata in
self._container_registry.findContainersMetadata(type = "material") if self._container_registry.findContainersMetadata(type = "material") if
metadata.get("GUID")} metadata.get("GUID")} # type: Dict[str, Dict[str, Any]]
self._material_group_map = dict() self._material_group_map = dict() # type: Dict[str, MaterialGroup]
# Map #1 # Map #1
# root_material_id -> MaterialGroup # root_material_id -> MaterialGroup
@ -89,7 +102,7 @@ class MaterialManager(QObject):
if material_id == "empty_material": if material_id == "empty_material":
continue continue
root_material_id = material_metadata.get("base_file") root_material_id = material_metadata.get("base_file", "")
if root_material_id not in self._material_group_map: if root_material_id not in self._material_group_map:
self._material_group_map[root_material_id] = MaterialGroup(root_material_id, MaterialNode(material_metadatas[root_material_id])) self._material_group_map[root_material_id] = MaterialGroup(root_material_id, MaterialNode(material_metadatas[root_material_id]))
self._material_group_map[root_material_id].is_read_only = self._container_registry.isReadOnly(root_material_id) self._material_group_map[root_material_id].is_read_only = self._container_registry.isReadOnly(root_material_id)
@ -105,26 +118,26 @@ class MaterialManager(QObject):
# Map #1.5 # Map #1.5
# GUID -> material group list # GUID -> material group list
self._guid_material_groups_map = defaultdict(list) self._guid_material_groups_map = defaultdict(list) # type: Dict[str, List[MaterialGroup]]
for root_material_id, material_group in self._material_group_map.items(): for root_material_id, material_group in self._material_group_map.items():
guid = material_group.root_material_node.metadata["GUID"] guid = material_group.root_material_node.getMetaDataEntry("GUID", "")
self._guid_material_groups_map[guid].append(material_group) self._guid_material_groups_map[guid].append(material_group)
# Map #2 # Map #2
# Lookup table for material type -> fallback material metadata, only for read-only materials # Lookup table for material type -> fallback material metadata, only for read-only materials
grouped_by_type_dict = dict() grouped_by_type_dict = dict() # type: Dict[str, Any]
material_types_without_fallback = set() material_types_without_fallback = set()
for root_material_id, material_node in self._material_group_map.items(): for root_material_id, material_node in self._material_group_map.items():
material_type = material_node.root_material_node.metadata["material"] material_type = material_node.root_material_node.getMetaDataEntry("material", "")
if material_type not in grouped_by_type_dict: if material_type not in grouped_by_type_dict:
grouped_by_type_dict[material_type] = {"generic": None, grouped_by_type_dict[material_type] = {"generic": None,
"others": []} "others": []}
material_types_without_fallback.add(material_type) material_types_without_fallback.add(material_type)
brand = material_node.root_material_node.metadata["brand"] brand = material_node.root_material_node.getMetaDataEntry("brand", "")
if brand.lower() == "generic": if brand.lower() == "generic":
to_add = True to_add = True
if material_type in grouped_by_type_dict: if material_type in grouped_by_type_dict:
diameter = material_node.root_material_node.metadata.get("approximate_diameter") diameter = material_node.root_material_node.getMetaDataEntry("approximate_diameter", "")
if diameter != self._default_approximate_diameter_for_quality_search: if diameter != self._default_approximate_diameter_for_quality_search:
to_add = False # don't add if it's not the default diameter to_add = False # don't add if it's not the default diameter
@ -133,7 +146,7 @@ class MaterialManager(QObject):
# - if it's in the list, it means that is a new material without fallback # - if it's in the list, it means that is a new material without fallback
# - if it is not, then it is a custom material with a fallback material (parent) # - if it is not, then it is a custom material with a fallback material (parent)
if material_type in material_types_without_fallback: if material_type in material_types_without_fallback:
grouped_by_type_dict[material_type] = material_node.root_material_node.metadata grouped_by_type_dict[material_type] = material_node.root_material_node._metadata
material_types_without_fallback.remove(material_type) material_types_without_fallback.remove(material_type)
# Remove the materials that have no fallback materials # Remove the materials that have no fallback materials
@ -150,15 +163,15 @@ class MaterialManager(QObject):
self._diameter_material_map = dict() self._diameter_material_map = dict()
# Group the material IDs by the same name, material, brand, and color but with different diameters. # Group the material IDs by the same name, material, brand, and color but with different diameters.
material_group_dict = dict() material_group_dict = dict() # type: Dict[Tuple[Any], Dict[str, str]]
keys_to_fetch = ("name", "material", "brand", "color") keys_to_fetch = ("name", "material", "brand", "color")
for root_material_id, machine_node in self._material_group_map.items(): for root_material_id, machine_node in self._material_group_map.items():
root_material_metadata = machine_node.root_material_node.metadata root_material_metadata = machine_node.root_material_node._metadata
key_data = [] key_data_list = [] # type: List[Any]
for key in keys_to_fetch: for key in keys_to_fetch:
key_data.append(root_material_metadata.get(key)) key_data_list.append(machine_node.root_material_node.getMetaDataEntry(key))
key_data = tuple(key_data) key_data = cast(Tuple[Any], tuple(key_data_list)) # type: Tuple[Any]
# If the key_data doesn't exist, it doesn't matter if the material is read only... # If the key_data doesn't exist, it doesn't matter if the material is read only...
if key_data not in material_group_dict: if key_data not in material_group_dict:
@ -167,8 +180,8 @@ class MaterialManager(QObject):
# ...but if key_data exists, we just overwrite it if the material is read only, otherwise we skip it # ...but if key_data exists, we just overwrite it if the material is read only, otherwise we skip it
if not machine_node.is_read_only: if not machine_node.is_read_only:
continue continue
approximate_diameter = root_material_metadata.get("approximate_diameter") approximate_diameter = machine_node.root_material_node.getMetaDataEntry("approximate_diameter", "")
material_group_dict[key_data][approximate_diameter] = root_material_metadata["id"] material_group_dict[key_data][approximate_diameter] = machine_node.root_material_node.getMetaDataEntry("id", "")
# Map [root_material_id][diameter] -> root_material_id for this diameter # Map [root_material_id][diameter] -> root_material_id for this diameter
for data_dict in material_group_dict.values(): for data_dict in material_group_dict.values():
@ -186,51 +199,81 @@ class MaterialManager(QObject):
self._diameter_material_map[root_material_id] = default_root_material_id self._diameter_material_map[root_material_id] = default_root_material_id
# Map #4 # Map #4
# "machine" -> "variant_name" -> "root material ID" -> specific material InstanceContainer # "machine" -> "nozzle name" -> "buildplate name" -> "root material ID" -> specific material InstanceContainer
# Construct the "machine" -> "variant" -> "root material ID" -> specific material InstanceContainer self._diameter_machine_nozzle_buildplate_material_map = dict() # type: Dict[str, Dict[str, MaterialNode]]
self._diameter_machine_variant_material_map = dict()
for material_metadata in material_metadatas.values(): for material_metadata in material_metadatas.values():
self.__addMaterialMetadataIntoLookupTree(material_metadata)
favorites = self._application.getPreferences().getValue("cura/favorite_materials")
for item in favorites.split(";"):
self._favorites.add(item)
self.materialsUpdated.emit()
def __addMaterialMetadataIntoLookupTree(self, material_metadata: Dict[str, Any]) -> None:
material_id = material_metadata["id"]
# We don't store empty material in the lookup tables # We don't store empty material in the lookup tables
if material_metadata["id"] == "empty_material": if material_id == "empty_material":
continue return
root_material_id = material_metadata["base_file"] root_material_id = material_metadata["base_file"]
definition = material_metadata["definition"] definition = material_metadata["definition"]
approximate_diameter = material_metadata["approximate_diameter"] approximate_diameter = str(material_metadata["approximate_diameter"])
if approximate_diameter not in self._diameter_machine_variant_material_map: if approximate_diameter not in self._diameter_machine_nozzle_buildplate_material_map:
self._diameter_machine_variant_material_map[approximate_diameter] = {} self._diameter_machine_nozzle_buildplate_material_map[approximate_diameter] = {}
machine_variant_material_map = self._diameter_machine_variant_material_map[approximate_diameter] machine_nozzle_buildplate_material_map = self._diameter_machine_nozzle_buildplate_material_map[
if definition not in machine_variant_material_map: approximate_diameter]
machine_variant_material_map[definition] = MaterialNode() if definition not in machine_nozzle_buildplate_material_map:
machine_nozzle_buildplate_material_map[definition] = MaterialNode()
machine_node = machine_variant_material_map[definition] # This is a list of information regarding the intermediate nodes:
variant_name = material_metadata.get("variant_name") # nozzle -> buildplate
if not variant_name: nozzle_name = material_metadata.get("variant_name")
# if there is no variant, this material is for the machine, so put its metadata in the machine node. buildplate_name = material_metadata.get("buildplate_name")
machine_node.material_map[root_material_id] = MaterialNode(material_metadata) intermediate_node_info_list = [(nozzle_name, VariantType.NOZZLE),
else: (buildplate_name, VariantType.BUILD_PLATE),
# this material is variant-specific, so we save it in a variant-specific node under the ]
# machine-specific node
# Check first if the variant exist in the manager variant_manager = self._application.getVariantManager()
existing_variant = self._application.getVariantManager().getVariantNode(definition, variant_name)
if existing_variant is not None:
if variant_name not in machine_node.children_map:
machine_node.children_map[variant_name] = MaterialNode()
variant_node = machine_node.children_map[variant_name] machine_node = machine_nozzle_buildplate_material_map[definition]
if root_material_id in variant_node.material_map: # We shouldn't have duplicated variant-specific materials for the same machine. current_node = machine_node
ConfigurationErrorMessage.getInstance().addFaultyContainers(root_material_id) current_intermediate_node_info_idx = 0
continue error_message = None # type: Optional[str]
variant_node.material_map[root_material_id] = MaterialNode(material_metadata) while current_intermediate_node_info_idx < len(intermediate_node_info_list):
else: variant_name, variant_type = intermediate_node_info_list[current_intermediate_node_info_idx]
# Add this container id to the wrong containers list in the registry if variant_name is not None:
Logger.log("w", "Not adding {id} to the material manager because the variant does not exist.".format(id = material_metadata["id"])) # The new material has a specific variant, so it needs to be added to that specific branch in the tree.
variant = variant_manager.getVariantNode(definition, variant_name, variant_type)
if variant is None:
error_message = "Material {id} contains a variant {name} that does not exist.".format(
id = material_metadata["id"], name = variant_name)
break
# Update the current node to advance to a more specific branch
if variant_name not in current_node.children_map:
current_node.children_map[variant_name] = MaterialNode()
current_node = current_node.children_map[variant_name]
current_intermediate_node_info_idx += 1
if error_message is not None:
Logger.log("e", "%s It will not be added into the material lookup tree.", error_message)
self._container_registry.addWrongContainerId(material_metadata["id"]) self._container_registry.addWrongContainerId(material_metadata["id"])
return
self.materialsUpdated.emit() # Add the material to the current tree node, which is the deepest (the most specific) branch we can find.
# Sanity check: Make sure that there is no duplicated materials.
if root_material_id in current_node.material_map:
Logger.log("e", "Duplicated material [%s] with root ID [%s]. It has already been added.",
material_id, root_material_id)
ConfigurationErrorMessage.getInstance().addFaultyContainers(root_material_id)
return
current_node.material_map[root_material_id] = MaterialNode(material_metadata)
def _updateMaps(self): def _updateMaps(self):
Logger.log("i", "Updating material lookup data ...") Logger.log("i", "Updating material lookup data ...")
@ -254,67 +297,82 @@ class MaterialManager(QObject):
return self._material_diameter_map.get(root_material_id, {}).get(approximate_diameter, root_material_id) return self._material_diameter_map.get(root_material_id, {}).get(approximate_diameter, root_material_id)
def getRootMaterialIDWithoutDiameter(self, root_material_id: str) -> str: def getRootMaterialIDWithoutDiameter(self, root_material_id: str) -> str:
return self._diameter_material_map.get(root_material_id) return self._diameter_material_map.get(root_material_id, "")
def getMaterialGroupListByGUID(self, guid: str) -> Optional[list]: def getMaterialGroupListByGUID(self, guid: str) -> Optional[List[MaterialGroup]]:
return self._guid_material_groups_map.get(guid) return self._guid_material_groups_map.get(guid)
# Returns a dict of all material groups organized by root_material_id.
def getAllMaterialGroups(self) -> Dict[str, "MaterialGroup"]:
return self._material_group_map
# #
# Return a dict with all root material IDs (k) and ContainerNodes (v) that's suitable for the given setup. # Return a dict with all root material IDs (k) and ContainerNodes (v) that's suitable for the given setup.
# #
def getAvailableMaterials(self, machine_definition: "DefinitionContainer", extruder_variant_name: Optional[str], def getAvailableMaterials(self, machine_definition: "DefinitionContainer", nozzle_name: Optional[str],
diameter: float) -> dict: buildplate_name: Optional[str], diameter: float) -> Dict[str, MaterialNode]:
# round the diameter to get the approximate diameter # round the diameter to get the approximate diameter
rounded_diameter = str(round(diameter)) rounded_diameter = str(round(diameter))
if rounded_diameter not in self._diameter_machine_variant_material_map: if rounded_diameter not in self._diameter_machine_nozzle_buildplate_material_map:
Logger.log("i", "Cannot find materials with diameter [%s] (rounded to [%s])", diameter, rounded_diameter) Logger.log("i", "Cannot find materials with diameter [%s] (rounded to [%s])", diameter, rounded_diameter)
return dict() return dict()
machine_definition_id = machine_definition.getId() machine_definition_id = machine_definition.getId()
# If there are variant materials, get the variant material # If there are nozzle-and-or-buildplate materials, get the nozzle-and-or-buildplate material
machine_variant_material_map = self._diameter_machine_variant_material_map[rounded_diameter] machine_nozzle_buildplate_material_map = self._diameter_machine_nozzle_buildplate_material_map[rounded_diameter]
machine_node = machine_variant_material_map.get(machine_definition_id) machine_node = machine_nozzle_buildplate_material_map.get(machine_definition_id)
default_machine_node = machine_variant_material_map.get(self._default_machine_definition_id) default_machine_node = machine_nozzle_buildplate_material_map.get(self._default_machine_definition_id)
variant_node = None nozzle_node = None
if extruder_variant_name is not None and machine_node is not None: buildplate_node = None
variant_node = machine_node.getChildNode(extruder_variant_name) if nozzle_name is not None and machine_node is not None:
nozzle_node = machine_node.getChildNode(nozzle_name)
nodes_to_check = [variant_node, machine_node, default_machine_node] # Get buildplate node if possible
if nozzle_node is not None and buildplate_name is not None:
buildplate_node = nozzle_node.getChildNode(buildplate_name)
nodes_to_check = [buildplate_node, nozzle_node, machine_node, default_machine_node]
# Fallback mechanism of finding materials: # Fallback mechanism of finding materials:
# 1. variant-specific material # 1. buildplate-specific material
# 2. machine-specific material # 2. nozzle-specific material
# 3. generic material (for fdmprinter) # 3. machine-specific material
# 4. generic material (for fdmprinter)
machine_exclude_materials = machine_definition.getMetaDataEntry("exclude_materials", []) machine_exclude_materials = machine_definition.getMetaDataEntry("exclude_materials", [])
material_id_metadata_dict = dict() material_id_metadata_dict = dict() # type: Dict[str, MaterialNode]
for node in nodes_to_check: excluded_materials = set()
if node is not None: for current_node in nodes_to_check:
for material_id, node in node.material_map.items(): if current_node is None:
fallback_id = self.getFallbackMaterialIdByMaterialType(node.metadata["material"]) continue
if fallback_id in machine_exclude_materials:
Logger.log("d", "Exclude material [%s] for machine [%s]", # Only exclude the materials that are explicitly specified in the "exclude_materials" field.
material_id, machine_definition.getId()) # Do not exclude other materials that are of the same type.
for material_id, node in current_node.material_map.items():
if material_id in machine_exclude_materials:
excluded_materials.add(material_id)
continue continue
if material_id not in material_id_metadata_dict: if material_id not in material_id_metadata_dict:
material_id_metadata_dict[material_id] = node material_id_metadata_dict[material_id] = node
if excluded_materials:
Logger.log("d", "Exclude materials {excluded_materials} for machine {machine_definition_id}".format(excluded_materials = ", ".join(excluded_materials), machine_definition_id = machine_definition_id))
return material_id_metadata_dict return material_id_metadata_dict
# #
# A convenience function to get available materials for the given machine with the extruder position. # A convenience function to get available materials for the given machine with the extruder position.
# #
def getAvailableMaterialsForMachineExtruder(self, machine: "GlobalStack", def getAvailableMaterialsForMachineExtruder(self, machine: "GlobalStack",
extruder_stack: "ExtruderStack") -> Optional[dict]: extruder_stack: "ExtruderStack") -> Optional[Dict[str, MaterialNode]]:
variant_name = None buildplate_name = machine.getBuildplateName()
nozzle_name = None
if extruder_stack.variant.getId() != "empty_variant": if extruder_stack.variant.getId() != "empty_variant":
variant_name = extruder_stack.variant.getName() nozzle_name = extruder_stack.variant.getName()
diameter = extruder_stack.approximateMaterialDiameter diameter = extruder_stack.getApproximateMaterialDiameter()
# Fetch the available materials (ContainerNode) for the current active machine and extruder setup. # Fetch the available materials (ContainerNode) for the current active machine and extruder setup.
return self.getAvailableMaterials(machine.definition, variant_name, diameter) return self.getAvailableMaterials(machine.definition, nozzle_name, buildplate_name, diameter)
# #
# Gets MaterialNode for the given extruder and machine with the given material name. # Gets MaterialNode for the given extruder and machine with the given material name.
@ -322,32 +380,36 @@ class MaterialManager(QObject):
# 1. the given machine doesn't have materials; # 1. the given machine doesn't have materials;
# 2. cannot find any material InstanceContainers with the given settings. # 2. cannot find any material InstanceContainers with the given settings.
# #
def getMaterialNode(self, machine_definition_id: str, extruder_variant_name: Optional[str], def getMaterialNode(self, machine_definition_id: str, nozzle_name: Optional[str],
diameter: float, root_material_id: str) -> Optional["InstanceContainer"]: buildplate_name: Optional[str], diameter: float, root_material_id: str) -> Optional["MaterialNode"]:
# round the diameter to get the approximate diameter # round the diameter to get the approximate diameter
rounded_diameter = str(round(diameter)) rounded_diameter = str(round(diameter))
if rounded_diameter not in self._diameter_machine_variant_material_map: if rounded_diameter not in self._diameter_machine_nozzle_buildplate_material_map:
Logger.log("i", "Cannot find materials with diameter [%s] (rounded to [%s]) for root material id [%s]", Logger.log("i", "Cannot find materials with diameter [%s] (rounded to [%s]) for root material id [%s]",
diameter, rounded_diameter, root_material_id) diameter, rounded_diameter, root_material_id)
return None return None
# If there are variant materials, get the variant material # If there are nozzle materials, get the nozzle-specific material
machine_variant_material_map = self._diameter_machine_variant_material_map[rounded_diameter] machine_nozzle_buildplate_material_map = self._diameter_machine_nozzle_buildplate_material_map[rounded_diameter] # type: Dict[str, MaterialNode]
machine_node = machine_variant_material_map.get(machine_definition_id) machine_node = machine_nozzle_buildplate_material_map.get(machine_definition_id)
variant_node = None nozzle_node = None
buildplate_node = None
# Fallback for "fdmprinter" if the machine-specific materials cannot be found # Fallback for "fdmprinter" if the machine-specific materials cannot be found
if machine_node is None: if machine_node is None:
machine_node = machine_variant_material_map.get(self._default_machine_definition_id) machine_node = machine_nozzle_buildplate_material_map.get(self._default_machine_definition_id)
if machine_node is not None and extruder_variant_name is not None: if machine_node is not None and nozzle_name is not None:
variant_node = machine_node.getChildNode(extruder_variant_name) nozzle_node = machine_node.getChildNode(nozzle_name)
if nozzle_node is not None and buildplate_name is not None:
buildplate_node = nozzle_node.getChildNode(buildplate_name)
# Fallback mechanism of finding materials: # Fallback mechanism of finding materials:
# 1. variant-specific material # 1. buildplate-specific material
# 2. machine-specific material # 2. nozzle-specific material
# 3. generic material (for fdmprinter) # 3. machine-specific material
nodes_to_check = [variant_node, machine_node, # 4. generic material (for fdmprinter)
machine_variant_material_map.get(self._default_machine_definition_id)] nodes_to_check = [buildplate_node, nozzle_node, machine_node,
machine_nozzle_buildplate_material_map.get(self._default_machine_definition_id)]
material_node = None material_node = None
for node in nodes_to_check: for node in nodes_to_check:
@ -364,29 +426,52 @@ class MaterialManager(QObject):
# 1. the given machine doesn't have materials; # 1. the given machine doesn't have materials;
# 2. cannot find any material InstanceContainers with the given settings. # 2. cannot find any material InstanceContainers with the given settings.
# #
def getMaterialNodeByType(self, global_stack: "GlobalStack", extruder_variant_name: str, material_guid: str) -> Optional["MaterialNode"]: def getMaterialNodeByType(self, global_stack: "GlobalStack", position: str, nozzle_name: str,
buildplate_name: Optional[str], material_guid: str) -> Optional["MaterialNode"]:
node = None node = None
machine_definition = global_stack.definition machine_definition = global_stack.definition
extruder_definition = global_stack.extruders[position].definition
if parseBool(machine_definition.getMetaDataEntry("has_materials", False)): if parseBool(machine_definition.getMetaDataEntry("has_materials", False)):
material_diameter = machine_definition.getProperty("material_diameter", "value") material_diameter = extruder_definition.getProperty("material_diameter", "value")
if isinstance(material_diameter, SettingFunction): if isinstance(material_diameter, SettingFunction):
material_diameter = material_diameter(global_stack) material_diameter = material_diameter(global_stack)
# Look at the guid to material dictionary # Look at the guid to material dictionary
root_material_id = None root_material_id = None
for material_group in self._guid_material_groups_map[material_guid]: for material_group in self._guid_material_groups_map[material_guid]:
if material_group.is_read_only: root_material_id = cast(str, material_group.root_material_node.getMetaDataEntry("id", ""))
root_material_id = material_group.root_material_node.metadata["id"]
break break
if not root_material_id: if not root_material_id:
Logger.log("i", "Cannot find materials with guid [%s] ", material_guid) Logger.log("i", "Cannot find materials with guid [%s] ", material_guid)
return None return None
node = self.getMaterialNode(machine_definition.getId(), extruder_variant_name, node = self.getMaterialNode(machine_definition.getId(), nozzle_name, buildplate_name,
material_diameter, root_material_id) material_diameter, root_material_id)
return node return node
# There are 2 ways to get fallback materials;
# - A fallback by type (@sa getFallbackMaterialIdByMaterialType), which adds the generic version of this material
# - A fallback by GUID; If a material has been duplicated, it should also check if the original materials do have
# a GUID. This should only be done if the material itself does not have a quality just yet.
def getFallBackMaterialIdsByMaterial(self, material: "InstanceContainer") -> List[str]:
results = [] # type: List[str]
material_groups = self.getMaterialGroupListByGUID(material.getMetaDataEntry("GUID"))
for material_group in material_groups: # type: ignore
if material_group.name != material.getId():
# If the material in the group is read only, put it at the front of the list (since that is the most
# likely one to get a result)
if material_group.is_read_only:
results.insert(0, material_group.name)
else:
results.append(material_group.name)
fallback = self.getFallbackMaterialIdByMaterialType(material.getMetaDataEntry("material"))
if fallback is not None:
results.append(fallback)
return results
# #
# Used by QualityManager. Built-in quality profiles may be based on generic material IDs such as "generic_pla". # Used by QualityManager. Built-in quality profiles may be based on generic material IDs such as "generic_pla".
# For materials such as ultimaker_pla_orange, no quality profiles may be found, so we should fall back to use # For materials such as ultimaker_pla_orange, no quality profiles may be found, so we should fall back to use
@ -411,17 +496,36 @@ class MaterialManager(QObject):
else: else:
return None return None
def getDefaultMaterial(self, global_stack: "GlobalStack", extruder_variant_name: Optional[str]) -> Optional["MaterialNode"]: ## Get default material for given global stack, extruder position and extruder nozzle name
# you can provide the extruder_definition and then the position is ignored (useful when building up global stack in CuraStackBuilder)
def getDefaultMaterial(self, global_stack: "GlobalStack", position: str, nozzle_name: Optional[str],
extruder_definition: Optional["DefinitionContainer"] = None) -> Optional["MaterialNode"]:
node = None node = None
buildplate_name = global_stack.getBuildplateName()
machine_definition = global_stack.definition machine_definition = global_stack.definition
if parseBool(global_stack.getMetaDataEntry("has_materials", False)):
material_diameter = machine_definition.getProperty("material_diameter", "value") # The extruder-compatible material diameter in the extruder definition may not be the correct value because
# the user can change it in the definition_changes container.
if extruder_definition is None:
extruder_stack_or_definition = global_stack.extruders[position]
is_extruder_stack = True
else:
extruder_stack_or_definition = extruder_definition
is_extruder_stack = False
if extruder_stack_or_definition and parseBool(global_stack.getMetaDataEntry("has_materials", False)):
if is_extruder_stack:
material_diameter = extruder_stack_or_definition.getCompatibleMaterialDiameter()
else:
material_diameter = extruder_stack_or_definition.getProperty("material_diameter", "value")
if isinstance(material_diameter, SettingFunction): if isinstance(material_diameter, SettingFunction):
material_diameter = material_diameter(global_stack) material_diameter = material_diameter(global_stack)
approximate_material_diameter = str(round(material_diameter)) approximate_material_diameter = str(round(material_diameter))
root_material_id = machine_definition.getMetaDataEntry("preferred_material") root_material_id = machine_definition.getMetaDataEntry("preferred_material")
root_material_id = self.getRootMaterialIDForDiameter(root_material_id, approximate_material_diameter) root_material_id = self.getRootMaterialIDForDiameter(root_material_id, approximate_material_diameter)
node = self.getMaterialNode(machine_definition.getId(), extruder_variant_name, node = self.getMaterialNode(machine_definition.getId(), nozzle_name, buildplate_name,
material_diameter, root_material_id) material_diameter, root_material_id)
return node return node
@ -432,33 +536,62 @@ class MaterialManager(QObject):
return return
nodes_to_remove = [material_group.root_material_node] + material_group.derived_material_node_list nodes_to_remove = [material_group.root_material_node] + material_group.derived_material_node_list
# Sort all nodes with respect to the container ID lengths in the ascending order so the base material container
# will be the first one to be removed. We need to do this to ensure that all containers get loaded & deleted.
nodes_to_remove = sorted(nodes_to_remove, key = lambda x: len(x.getMetaDataEntry("id", "")))
# Try to load all containers first. If there is any faulty ones, they will be put into the faulty container
# list, so removeContainer() can ignore those ones.
for node in nodes_to_remove: for node in nodes_to_remove:
self._container_registry.removeContainer(node.metadata["id"]) container_id = node.getMetaDataEntry("id", "")
results = self._container_registry.findContainers(id = container_id)
if not results:
self._container_registry.addWrongContainerId(container_id)
for node in nodes_to_remove:
self._container_registry.removeContainer(node.getMetaDataEntry("id", ""))
# #
# Methods for GUI # Methods for GUI
# #
@pyqtSlot("QVariant", result=bool)
def canMaterialBeRemoved(self, material_node: "MaterialNode"):
# Check if the material is active in any extruder train. In that case, the material shouldn't be removed!
# In the future we might enable this again, but right now, it's causing a ton of issues if we do (since it
# corrupts the configuration)
root_material_id = material_node.getMetaDataEntry("base_file")
material_group = self.getMaterialGroup(root_material_id)
if not material_group:
return False
nodes_to_remove = [material_group.root_material_node] + material_group.derived_material_node_list
ids_to_remove = [node.getMetaDataEntry("id", "") for node in nodes_to_remove]
for extruder_stack in self._container_registry.findContainerStacks(type="extruder_train"):
if extruder_stack.material.getId() in ids_to_remove:
return False
return True
#
# Sets the new name for the given material.
#
@pyqtSlot("QVariant", str) @pyqtSlot("QVariant", str)
def setMaterialName(self, material_node: "MaterialNode", name: str): def setMaterialName(self, material_node: "MaterialNode", name: str) -> None:
root_material_id = material_node.metadata["base_file"] root_material_id = material_node.getMetaDataEntry("base_file")
if root_material_id is None:
return
if self._container_registry.isReadOnly(root_material_id): if self._container_registry.isReadOnly(root_material_id):
Logger.log("w", "Cannot set name of read-only container %s.", root_material_id) Logger.log("w", "Cannot set name of read-only container %s.", root_material_id)
return return
material_group = self.getMaterialGroup(root_material_id) material_group = self.getMaterialGroup(root_material_id)
if material_group: if material_group:
material_group.root_material_node.getContainer().setName(name) container = material_group.root_material_node.getContainer()
if container:
container.setName(name)
# #
# Removes the given material. # Removes the given material.
# #
@pyqtSlot("QVariant") @pyqtSlot("QVariant")
def removeMaterial(self, material_node: "MaterialNode"): def removeMaterial(self, material_node: "MaterialNode") -> None:
root_material_id = material_node.metadata["base_file"] root_material_id = material_node.getMetaDataEntry("base_file")
if root_material_id is not None:
self.removeMaterialByRootId(root_material_id) self.removeMaterialByRootId(root_material_id)
# #
@ -466,8 +599,8 @@ class MaterialManager(QObject):
# Returns the root material ID of the duplicated material if successful. # Returns the root material ID of the duplicated material if successful.
# #
@pyqtSlot("QVariant", result = str) @pyqtSlot("QVariant", result = str)
def duplicateMaterial(self, material_node, new_base_id = None, new_metadata = None) -> Optional[str]: def duplicateMaterial(self, material_node: MaterialNode, new_base_id: Optional[str] = None, new_metadata: Dict[str, Any] = None) -> Optional[str]:
root_material_id = material_node.metadata["base_file"] root_material_id = cast(str, material_node.getMetaDataEntry("base_file", ""))
material_group = self.getMaterialGroup(root_material_id) material_group = self.getMaterialGroup(root_material_id)
if not material_group: if not material_group:
@ -503,8 +636,8 @@ class MaterialManager(QObject):
if container_to_copy.getMetaDataEntry("definition") != "fdmprinter": if container_to_copy.getMetaDataEntry("definition") != "fdmprinter":
new_id += "_" + container_to_copy.getMetaDataEntry("definition") new_id += "_" + container_to_copy.getMetaDataEntry("definition")
if container_to_copy.getMetaDataEntry("variant_name"): if container_to_copy.getMetaDataEntry("variant_name"):
variant_name = container_to_copy.getMetaDataEntry("variant_name") nozzle_name = container_to_copy.getMetaDataEntry("variant_name")
new_id += "_" + variant_name.replace(" ", "_") new_id += "_" + nozzle_name.replace(" ", "_")
new_container = copy.deepcopy(container_to_copy) new_container = copy.deepcopy(container_to_copy)
new_container.getMetaData()["id"] = new_id new_container.getMetaData()["id"] = new_id
@ -518,11 +651,16 @@ class MaterialManager(QObject):
for container_to_add in new_containers: for container_to_add in new_containers:
container_to_add.setDirty(True) container_to_add.setDirty(True)
self._container_registry.addContainer(container_to_add) self._container_registry.addContainer(container_to_add)
# if the duplicated material was favorite then the new material should also be added to favorite.
if root_material_id in self.getFavorites():
self.addFavorite(new_base_id)
return new_base_id return new_base_id
# #
# Create a new material by cloning Generic PLA for the current material diameter and generate a new GUID. # Create a new material by cloning Generic PLA for the current material diameter and generate a new GUID.
# # Returns the ID of the newly created material.
@pyqtSlot(result = str) @pyqtSlot(result = str)
def createMaterial(self) -> str: def createMaterial(self) -> str:
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
@ -533,11 +671,17 @@ class MaterialManager(QObject):
machine_manager = self._application.getMachineManager() machine_manager = self._application.getMachineManager()
extruder_stack = machine_manager.activeStack extruder_stack = machine_manager.activeStack
machine_definition = self._application.getGlobalContainerStack().definition
root_material_id = machine_definition.getMetaDataEntry("preferred_material", default = "generic_pla")
approximate_diameter = str(extruder_stack.approximateMaterialDiameter) approximate_diameter = str(extruder_stack.approximateMaterialDiameter)
root_material_id = "generic_pla"
root_material_id = self.getRootMaterialIDForDiameter(root_material_id, approximate_diameter) root_material_id = self.getRootMaterialIDForDiameter(root_material_id, approximate_diameter)
material_group = self.getMaterialGroup(root_material_id) material_group = self.getMaterialGroup(root_material_id)
if not material_group: # This should never happen
Logger.log("w", "Cannot get the material group of %s.", root_material_id)
return ""
# Create a new ID & container to hold the data. # Create a new ID & container to hold the data.
new_id = self._container_registry.uniqueName("custom_material") new_id = self._container_registry.uniqueName("custom_material")
new_metadata = {"name": catalog.i18nc("@label", "Custom Material"), new_metadata = {"name": catalog.i18nc("@label", "Custom Material"),
@ -549,3 +693,29 @@ class MaterialManager(QObject):
new_base_id = new_id, new_base_id = new_id,
new_metadata = new_metadata) new_metadata = new_metadata)
return new_id return new_id
@pyqtSlot(str)
def addFavorite(self, root_material_id: str) -> None:
self._favorites.add(root_material_id)
self.materialsUpdated.emit()
# Ensure all settings are saved.
self._application.getPreferences().setValue("cura/favorite_materials", ";".join(list(self._favorites)))
self._application.saveSettings()
@pyqtSlot(str)
def removeFavorite(self, root_material_id: str) -> None:
try:
self._favorites.remove(root_material_id)
except KeyError:
Logger.log("w", "Could not delete material %s from favorites as it was already deleted", root_material_id)
return
self.materialsUpdated.emit()
# Ensure all settings are saved.
self._application.getPreferences().setValue("cura/favorite_materials", ";".join(list(self._favorites)))
self._application.saveSettings()
@pyqtSlot()
def getFavorites(self):
return self._favorites

View file

@ -1,8 +1,7 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, Dict, Any
from typing import Optional from collections import OrderedDict
from .ContainerNode import ContainerNode from .ContainerNode import ContainerNode
@ -15,7 +14,12 @@ from .ContainerNode import ContainerNode
class MaterialNode(ContainerNode): class MaterialNode(ContainerNode):
__slots__ = ("material_map", "children_map") __slots__ = ("material_map", "children_map")
def __init__(self, metadata: Optional[dict] = None): def __init__(self, metadata: Optional[Dict[str, Any]] = None) -> None:
super().__init__(metadata = metadata) super().__init__(metadata = metadata)
self.material_map = {} # material_root_id -> material_node self.material_map = {} # type: Dict[str, MaterialNode] # material_root_id -> material_node
self.children_map = {} # mapping for the child nodes
# We overide this as we want to indicate that MaterialNodes can only contain other material nodes.
self.children_map = OrderedDict() # type: OrderedDict[str, "MaterialNode"]
def getChildNode(self, child_key: str) -> Optional["MaterialNode"]:
return self.children_map.get(child_key)

View file

@ -1,45 +1,67 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, Dict, Set
from PyQt5.QtCore import Qt, pyqtSignal, pyqtProperty from PyQt5.QtCore import Qt, pyqtSignal, pyqtProperty
from UM.Application import Application
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
# ## This is the base model class for GenericMaterialsModel and MaterialBrandsModel.
# This is the base model class for GenericMaterialsModel and BrandMaterialsModel
# Those 2 models are used by the material drop down menu to show generic materials and branded materials separately. # 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 # 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 # bar menu "Settings" -> "Extruder nr" -> "Material" -> this menu
# from cura.Machines.MaterialNode import MaterialNode
class BaseMaterialsModel(ListModel): class BaseMaterialsModel(ListModel):
RootMaterialIdRole = Qt.UserRole + 1
IdRole = Qt.UserRole + 2
NameRole = Qt.UserRole + 3
BrandRole = Qt.UserRole + 4
MaterialRole = Qt.UserRole + 5
ColorRole = Qt.UserRole + 6
ContainerNodeRole = Qt.UserRole + 7
extruderPositionChanged = pyqtSignal() extruderPositionChanged = pyqtSignal()
enabledChanged = pyqtSignal()
def __init__(self, parent = None): def __init__(self, parent = None):
super().__init__(parent) super().__init__(parent)
self._application = Application.getInstance() from cura.CuraApplication import CuraApplication
self._machine_manager = self._application.getMachineManager()
self.addRoleName(self.RootMaterialIdRole, "root_material_id") self._application = CuraApplication.getInstance()
self.addRoleName(self.IdRole, "id")
self.addRoleName(self.NameRole, "name") # Make these managers available to all material models
self.addRoleName(self.BrandRole, "brand") self._container_registry = self._application.getInstance().getContainerRegistry()
self.addRoleName(self.MaterialRole, "material") self._machine_manager = self._application.getMachineManager()
self.addRoleName(self.ColorRole, "color_name") self._material_manager = self._application.getMaterialManager()
self.addRoleName(self.ContainerNodeRole, "container_node")
# Update the stack and the model data when the machine changes
self._machine_manager.globalContainerChanged.connect(self._updateExtruderStack)
# Update this model when switching machines
self._machine_manager.activeStackChanged.connect(self._update)
# Update this model when list of materials changes
self._material_manager.materialsUpdated.connect(self._update)
self.addRoleName(Qt.UserRole + 1, "root_material_id")
self.addRoleName(Qt.UserRole + 2, "id")
self.addRoleName(Qt.UserRole + 3, "GUID")
self.addRoleName(Qt.UserRole + 4, "name")
self.addRoleName(Qt.UserRole + 5, "brand")
self.addRoleName(Qt.UserRole + 6, "description")
self.addRoleName(Qt.UserRole + 7, "material")
self.addRoleName(Qt.UserRole + 8, "color_name")
self.addRoleName(Qt.UserRole + 9, "color_code")
self.addRoleName(Qt.UserRole + 10, "density")
self.addRoleName(Qt.UserRole + 11, "diameter")
self.addRoleName(Qt.UserRole + 12, "approximate_diameter")
self.addRoleName(Qt.UserRole + 13, "adhesion_info")
self.addRoleName(Qt.UserRole + 14, "is_read_only")
self.addRoleName(Qt.UserRole + 15, "container_node")
self.addRoleName(Qt.UserRole + 16, "is_favorite")
self._extruder_position = 0 self._extruder_position = 0
self._extruder_stack = None self._extruder_stack = None
self._available_materials = None # type: Optional[Dict[str, MaterialNode]]
self._favorite_ids = set() # type: Set[str]
self._enabled = True
def _updateExtruderStack(self): def _updateExtruderStack(self):
global_stack = self._machine_manager.activeMachine global_stack = self._machine_manager.activeMachine
if global_stack is None: if global_stack is None:
@ -47,12 +69,16 @@ class BaseMaterialsModel(ListModel):
if self._extruder_stack is not None: if self._extruder_stack is not None:
self._extruder_stack.pyqtContainersChanged.disconnect(self._update) self._extruder_stack.pyqtContainersChanged.disconnect(self._update)
self._extruder_stack.approximateMaterialDiameterChanged.disconnect(self._update)
self._extruder_stack = global_stack.extruders.get(str(self._extruder_position)) self._extruder_stack = global_stack.extruders.get(str(self._extruder_position))
if self._extruder_stack is not None: if self._extruder_stack is not None:
self._extruder_stack.pyqtContainersChanged.connect(self._update) self._extruder_stack.pyqtContainersChanged.connect(self._update)
self._extruder_stack.approximateMaterialDiameterChanged.connect(self._update)
# Force update the model when the extruder stack changes
self._update()
def setExtruderPosition(self, position: int): def setExtruderPosition(self, position: int):
if self._extruder_position != position: if self._extruder_stack is None or self._extruder_position != position:
self._extruder_position = position self._extruder_position = position
self._updateExtruderStack() self._updateExtruderStack()
self.extruderPositionChanged.emit() self.extruderPositionChanged.emit()
@ -61,8 +87,66 @@ class BaseMaterialsModel(ListModel):
def extruderPosition(self) -> int: def extruderPosition(self) -> int:
return self._extruder_position return self._extruder_position
# def setEnabled(self, enabled):
# This is an abstract method that needs to be implemented by if self._enabled != enabled:
# self._enabled = enabled
if self._enabled:
# ensure the data is there again.
self._update()
self.enabledChanged.emit()
@pyqtProperty(bool, fset=setEnabled, notify=enabledChanged)
def enabled(self):
return self._enabled
## This is an abstract method that needs to be implemented by the specific
# models themselves.
def _update(self): def _update(self):
pass pass
## 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):
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:
return False
extruder_stack = global_stack.extruders[extruder_position]
self._available_materials = self._material_manager.getAvailableMaterialsForMachineExtruder(global_stack, extruder_stack)
if self._available_materials is None:
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):
metadata = container_node.getMetadata()
item = {
"root_material_id": root_material_id,
"id": metadata["id"],
"container_id": metadata["id"], # TODO: Remove duplicate in material manager qml
"GUID": metadata["GUID"],
"name": metadata["name"],
"brand": metadata["brand"],
"description": metadata["description"],
"material": metadata["material"],
"color_name": metadata["color_name"],
"color_code": metadata.get("color_code", ""),
"density": metadata.get("properties", {}).get("density", ""),
"diameter": metadata.get("properties", {}).get("diameter", ""),
"approximate_diameter": metadata["approximate_diameter"],
"adhesion_info": metadata["adhesion_info"],
"is_read_only": self._container_registry.isReadOnly(metadata["id"]),
"container_node": container_node,
"is_favorite": root_material_id in self._favorite_ids
}
return item

View file

@ -1,137 +0,0 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt, pyqtSignal, pyqtProperty
from UM.Qt.ListModel import ListModel
from UM.Logger import Logger
from cura.Machines.Models.BaseMaterialsModel import BaseMaterialsModel
#
# This is an intermediate model to group materials with different colours for a same brand and type.
#
class MaterialsModelGroupedByType(ListModel):
NameRole = Qt.UserRole + 1
ColorsRole = Qt.UserRole + 2
def __init__(self, parent = None):
super().__init__(parent)
self.addRoleName(self.NameRole, "name")
self.addRoleName(self.ColorsRole, "colors")
#
# This model is used to show branded materials in the material drop down menu.
# The structure of the menu looks like this:
# Brand -> Material Type -> list of materials
#
# To illustrate, a branded material menu may look like this:
# Ultimaker -> PLA -> Yellow PLA
# -> Black PLA
# -> ...
# -> ABS -> White ABS
# ...
#
class BrandMaterialsModel(ListModel):
NameRole = Qt.UserRole + 1
MaterialsRole = Qt.UserRole + 2
extruderPositionChanged = pyqtSignal()
def __init__(self, parent = None):
super().__init__(parent)
self.addRoleName(self.NameRole, "name")
self.addRoleName(self.MaterialsRole, "materials")
self._extruder_position = 0
from cura.CuraApplication import CuraApplication
self._machine_manager = CuraApplication.getInstance().getMachineManager()
self._extruder_manager = CuraApplication.getInstance().getExtruderManager()
self._material_manager = CuraApplication.getInstance().getMaterialManager()
self._machine_manager.activeStackChanged.connect(self._update) #Update when switching machines.
self._material_manager.materialsUpdated.connect(self._update) #Update when the list of materials changes.
self._update()
def setExtruderPosition(self, position: int):
if self._extruder_position != position:
self._extruder_position = position
self.extruderPositionChanged.emit()
@pyqtProperty(int, fset = setExtruderPosition, notify = extruderPositionChanged)
def extruderPosition(self) -> int:
return self._extruder_position
def _update(self):
Logger.log("d", "Updating {model_class_name}.".format(model_class_name = self.__class__.__name__))
global_stack = self._machine_manager.activeMachine
if global_stack is None:
self.setItems([])
return
extruder_position = str(self._extruder_position)
if extruder_position not in global_stack.extruders:
self.setItems([])
return
extruder_stack = global_stack.extruders[str(self._extruder_position)]
available_material_dict = self._material_manager.getAvailableMaterialsForMachineExtruder(global_stack,
extruder_stack)
if available_material_dict is None:
self.setItems([])
return
brand_item_list = []
brand_group_dict = {}
for root_material_id, container_node in available_material_dict.items():
metadata = container_node.metadata
brand = metadata["brand"]
# Only add results for generic materials
if brand.lower() == "generic":
continue
if brand not in brand_group_dict:
brand_group_dict[brand] = {}
material_type = metadata["material"]
if material_type not in brand_group_dict[brand]:
brand_group_dict[brand][material_type] = []
item = {"root_material_id": root_material_id,
"id": metadata["id"],
"name": metadata["name"],
"brand": metadata["brand"],
"material": metadata["material"],
"color_name": metadata["color_name"],
"container_node": container_node
}
brand_group_dict[brand][material_type].append(item)
for brand, material_dict in brand_group_dict.items():
brand_item = {"name": brand,
"materials": MaterialsModelGroupedByType(self)}
material_type_item_list = []
for material_type, material_list in material_dict.items():
material_type_item = {"name": material_type,
"colors": BaseMaterialsModel(self)}
material_type_item["colors"].clear()
# Sort materials by name
material_list = sorted(material_list, key = lambda x: x["name"].upper())
material_type_item["colors"].setItems(material_list)
material_type_item_list.append(material_type_item)
# Sort material type by name
material_type_item_list = sorted(material_type_item_list, key = lambda x: x["name"].upper())
brand_item["materials"].setItems(material_type_item_list)
brand_item_list.append(brand_item)
# Sort brand by name
brand_item_list = sorted(brand_item_list, key = lambda x: x["name"].upper())
self.setItems(brand_item_list)

View file

@ -8,7 +8,7 @@ from UM.Logger import Logger
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
from UM.Util import parseBool from UM.Util import parseBool
from cura.Machines.VariantManager import VariantType from cura.Machines.VariantType import VariantType
class BuildPlateModel(ListModel): class BuildPlateModel(ListModel):

View file

@ -0,0 +1,140 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Callable, Dict, List, Optional, TYPE_CHECKING
from PyQt5.QtCore import pyqtSlot, pyqtProperty, pyqtSignal, QObject
from UM.i18n import i18nCatalog
from UM.Logger import Logger
if TYPE_CHECKING:
from PyQt5.QtCore import QObject
from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice
catalog = i18nCatalog("cura")
class DiscoveredPrinter(QObject):
def __init__(self, ip_address: str, key: str, name: str, create_callback: Callable[[str], None], machine_type: str,
device: "NetworkedPrinterOutputDevice", parent: Optional["QObject"] = None) -> None:
super().__init__(parent)
self._ip_address = ip_address
self._key = key
self._name = name
self.create_callback = create_callback
self._machine_type = machine_type
self._device = device
nameChanged = pyqtSignal()
def getKey(self) -> str:
return self._key
@pyqtProperty(str, notify = nameChanged)
def name(self) -> str:
return self._name
def setName(self, name: str) -> None:
if self._name != name:
self._name = name
self.nameChanged.emit()
machineTypeChanged = pyqtSignal()
@pyqtProperty(str, notify = machineTypeChanged)
def machineType(self) -> str:
return self._machine_type
def setMachineType(self, machine_type: str) -> None:
if self._machine_type != machine_type:
self._machine_type = machine_type
self.machineTypeChanged.emit()
# Human readable machine type string
@pyqtProperty(str, notify = machineTypeChanged)
def readableMachineType(self) -> str:
from cura.CuraApplication import CuraApplication
readable_type = CuraApplication.getInstance().getMachineManager().getMachineTypeNameFromId(self._machine_type)
if not readable_type:
readable_type = catalog.i18nc("@label", "Unknown")
return readable_type
@pyqtProperty(bool, notify = machineTypeChanged)
def isUnknownMachineType(self) -> bool:
return self.readableMachineType.lower() == "unknown"
@pyqtProperty(QObject, constant = True)
def device(self) -> "NetworkedPrinterOutputDevice":
return self._device
#
# 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):
def __init__(self, parent: Optional["QObject"] = None) -> None:
super().__init__(parent)
self._discovered_printer_by_ip_dict = dict() # type: Dict[str, DiscoveredPrinter]
discoveredPrintersChanged = pyqtSignal()
@pyqtProperty(list, notify = discoveredPrintersChanged)
def discoveredPrinters(self) -> List["DiscoveredPrinter"]:
item_list = list(x for x in self._discovered_printer_by_ip_dict.values())
item_list.sort(key = lambda x: x.device.name)
return item_list
def addDiscoveredPrinter(self, ip_address: str, key: str, name: str, create_callback: Callable[[str], None],
machine_type: str, device: "NetworkedPrinterOutputDevice") -> None:
if ip_address in self._discovered_printer_by_ip_dict:
Logger.log("e", "Printer with ip [%s] has already been added", ip_address)
return
discovered_printer = DiscoveredPrinter(ip_address, key, name, create_callback, machine_type, device, parent = self)
self._discovered_printer_by_ip_dict[ip_address] = discovered_printer
self.discoveredPrintersChanged.emit()
def updateDiscoveredPrinter(self, ip_address: str,
name: Optional[str] = None,
machine_type: Optional[str] = None) -> None:
if ip_address not in self._discovered_printer_by_ip_dict:
Logger.log("w", "Printer with ip [%s] is not known", ip_address)
return
item = self._discovered_printer_by_ip_dict[ip_address]
if name is not None:
item.setName(name)
if machine_type is not None:
item.setMachineType(machine_type)
def removeDiscoveredPrinter(self, ip_address: str) -> None:
if ip_address not in self._discovered_printer_by_ip_dict:
Logger.log("w", "Key [%s] does not exist in the discovered printers list.", ip_address)
return
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:
discovered_printer.create_callback(discovered_printer.getKey())
@pyqtSlot(str)
def createMachineFromDiscoveredPrinterAddress(self, ip_address: str) -> None:
if ip_address not in self._discovered_printer_by_ip_dict:
Logger.log("i", "Key [%s] does not exist in the discovered printers list.", ip_address)
return
self.createMachineFromDiscoveredPrinter(self._discovered_printer_by_ip_dict[ip_address])

View file

@ -1,31 +1,31 @@
# Copyright (c) 2017 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot, pyqtProperty, QTimer from PyQt5.QtCore import Qt, pyqtSignal, pyqtProperty, QTimer
from typing import Iterable from typing import Iterable, TYPE_CHECKING
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
import UM.Qt.ListModel from UM.Qt.ListModel import ListModel
from UM.Application import Application from UM.Application import Application
import UM.FlameProfiler import UM.FlameProfiler
from cura.Settings.ExtruderStack import ExtruderStack # To listen to changes on the extruders. if TYPE_CHECKING:
from cura.Settings.ExtruderStack import ExtruderStack # To listen to changes on the extruders.
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
## Model that holds extruders. ## Model that holds extruders.
# #
# This model is designed for use by any list of extruders, but specifically # 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 # intended for drop-down lists of the current machine's extruders in place of
# settings. # settings.
class ExtrudersModel(UM.Qt.ListModel.ListModel): class ExtrudersModel(ListModel):
# The ID of the container stack for the extruder. # The ID of the container stack for the extruder.
IdRole = Qt.UserRole + 1 IdRole = Qt.UserRole + 1
## Human-readable name of the extruder. ## Human-readable name of the extruder.
NameRole = Qt.UserRole + 2 NameRole = Qt.UserRole + 2
## Is the extruder enabled?
EnabledRole = Qt.UserRole + 9
## Colour of the material loaded in the extruder. ## Colour of the material loaded in the extruder.
ColorRole = Qt.UserRole + 3 ColorRole = Qt.UserRole + 3
@ -47,6 +47,12 @@ class ExtrudersModel(UM.Qt.ListModel.ListModel):
VariantRole = Qt.UserRole + 7 VariantRole = Qt.UserRole + 7
StackRole = Qt.UserRole + 8 StackRole = Qt.UserRole + 8
MaterialBrandRole = Qt.UserRole + 9
ColorNameRole = Qt.UserRole + 10
## Is the extruder enabled?
EnabledRole = Qt.UserRole + 11
## List of colours to display if there is no material or the material has no known ## List of colours to display if there is no material or the material has no known
# colour. # colour.
defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"] defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"]
@ -67,14 +73,13 @@ class ExtrudersModel(UM.Qt.ListModel.ListModel):
self.addRoleName(self.MaterialRole, "material") self.addRoleName(self.MaterialRole, "material")
self.addRoleName(self.VariantRole, "variant") self.addRoleName(self.VariantRole, "variant")
self.addRoleName(self.StackRole, "stack") self.addRoleName(self.StackRole, "stack")
self.addRoleName(self.MaterialBrandRole, "material_brand")
self.addRoleName(self.ColorNameRole, "color_name")
self._update_extruder_timer = QTimer() self._update_extruder_timer = QTimer()
self._update_extruder_timer.setInterval(100) self._update_extruder_timer.setInterval(100)
self._update_extruder_timer.setSingleShot(True) self._update_extruder_timer.setSingleShot(True)
self._update_extruder_timer.timeout.connect(self.__updateExtruders) self._update_extruder_timer.timeout.connect(self.__updateExtruders)
self._simple_names = False
self._active_machine_extruders = [] # type: Iterable[ExtruderStack] self._active_machine_extruders = [] # type: Iterable[ExtruderStack]
self._add_optional_extruder = False self._add_optional_extruder = False
@ -96,21 +101,6 @@ class ExtrudersModel(UM.Qt.ListModel.ListModel):
def addOptionalExtruder(self): def addOptionalExtruder(self):
return self._add_optional_extruder return self._add_optional_extruder
## Set the simpleNames property.
def setSimpleNames(self, simple_names):
if simple_names != self._simple_names:
self._simple_names = simple_names
self.simpleNamesChanged.emit()
self._updateExtruders()
## Emitted when the simpleNames property changes.
simpleNamesChanged = pyqtSignal()
## Whether or not the model should show all definitions regardless of visibility.
@pyqtProperty(bool, fset = setSimpleNames, notify = simpleNamesChanged)
def simpleNames(self):
return self._simple_names
## Links to the stack-changed signal of the new extruders when an extruder ## Links to the stack-changed signal of the new extruders when an extruder
# is swapped out or added in the current machine. # is swapped out or added in the current machine.
# #
@ -119,32 +109,35 @@ class ExtrudersModel(UM.Qt.ListModel.ListModel):
# that signal. Application.globalContainerStackChanged doesn't fill this # that signal. Application.globalContainerStackChanged doesn't fill this
# signal; it's assumed to be the current printer in that case. # signal; it's assumed to be the current printer in that case.
def _extrudersChanged(self, machine_id = None): def _extrudersChanged(self, machine_id = None):
machine_manager = Application.getInstance().getMachineManager()
if machine_id is not None: if machine_id is not None:
if Application.getInstance().getGlobalContainerStack() is None: if machine_manager.activeMachine is None:
# No machine, don't need to update the current machine's extruders # No machine, don't need to update the current machine's extruders
return return
if machine_id != Application.getInstance().getGlobalContainerStack().getId(): if machine_id != machine_manager.activeMachine.getId():
# Not the current machine # Not the current machine
return return
# Unlink from old extruders # Unlink from old extruders
for extruder in self._active_machine_extruders: for extruder in self._active_machine_extruders:
extruder.containersChanged.disconnect(self._onExtruderStackContainersChanged) extruder.containersChanged.disconnect(self._onExtruderStackContainersChanged)
extruder.enabledChanged.disconnect(self._updateExtruders)
# Link to new extruders # Link to new extruders
self._active_machine_extruders = [] self._active_machine_extruders = []
extruder_manager = Application.getInstance().getExtruderManager() extruder_manager = Application.getInstance().getExtruderManager()
for extruder in extruder_manager.getExtruderStacks(): for extruder in extruder_manager.getActiveExtruderStacks():
if extruder is None: #This extruder wasn't loaded yet. This happens asynchronously while this model is constructed from QML. if extruder is None: #This extruder wasn't loaded yet. This happens asynchronously while this model is constructed from QML.
continue continue
extruder.containersChanged.connect(self._onExtruderStackContainersChanged) extruder.containersChanged.connect(self._onExtruderStackContainersChanged)
extruder.enabledChanged.connect(self._updateExtruders)
self._active_machine_extruders.append(extruder) self._active_machine_extruders.append(extruder)
self._updateExtruders() # Since the new extruders may have different properties, update our own model. self._updateExtruders() # Since the new extruders may have different properties, update our own model.
def _onExtruderStackContainersChanged(self, container): def _onExtruderStackContainersChanged(self, container):
# Update when there is an empty container or material change # Update when there is an empty container or material or variant change
if container.getMetaDataEntry("type") == "material" or container.getMetaDataEntry("type") is None: if container.getMetaDataEntry("type") in ["material", "variant", None]:
# The ExtrudersModel needs to be updated when the material-name or -color changes, because the user identifies extruders by material-name # The ExtrudersModel needs to be updated when the material-name or -color changes, because the user identifies extruders by material-name
self._updateExtruders() self._updateExtruders()
@ -160,7 +153,7 @@ class ExtrudersModel(UM.Qt.ListModel.ListModel):
def __updateExtruders(self): def __updateExtruders(self):
extruders_changed = False extruders_changed = False
if self.rowCount() != 0: if self.count != 0:
extruders_changed = True extruders_changed = True
items = [] items = []
@ -171,8 +164,8 @@ class ExtrudersModel(UM.Qt.ListModel.ListModel):
# get machine extruder count for verification # get machine extruder count for verification
machine_extruder_count = global_container_stack.getProperty("machine_extruder_count", "value") machine_extruder_count = global_container_stack.getProperty("machine_extruder_count", "value")
for extruder in Application.getInstance().getExtruderManager().getMachineExtruders(global_container_stack.getId()): for extruder in Application.getInstance().getExtruderManager().getActiveExtruderStacks():
position = extruder.getMetaDataEntry("position", default = "0") # Get the position position = extruder.getMetaDataEntry("position", default = "0")
try: try:
position = int(position) position = int(position)
except ValueError: except ValueError:
@ -183,7 +176,8 @@ class ExtrudersModel(UM.Qt.ListModel.ListModel):
default_color = self.defaultColors[position] if 0 <= position < len(self.defaultColors) else self.defaultColors[0] default_color = self.defaultColors[position] if 0 <= position < len(self.defaultColors) else self.defaultColors[0]
color = extruder.material.getMetaDataEntry("color_code", default = default_color) if extruder.material else default_color color = extruder.material.getMetaDataEntry("color_code", default = default_color) if extruder.material else default_color
material_brand = extruder.material.getMetaDataEntry("brand", default = "generic")
color_name = extruder.material.getMetaDataEntry("color_name")
# construct an item with only the relevant information # construct an item with only the relevant information
item = { item = {
"id": extruder.getId(), "id": extruder.getId(),
@ -195,6 +189,8 @@ class ExtrudersModel(UM.Qt.ListModel.ListModel):
"material": extruder.material.getName() if extruder.material else "", "material": extruder.material.getName() if extruder.material else "",
"variant": extruder.variant.getName() if extruder.variant else "", # e.g. print core "variant": extruder.variant.getName() if extruder.variant else "", # e.g. print core
"stack": extruder, "stack": extruder,
"material_brand": material_brand,
"color_name": color_name
} }
items.append(item) items.append(item)
@ -213,9 +209,14 @@ class ExtrudersModel(UM.Qt.ListModel.ListModel):
"enabled": True, "enabled": True,
"color": "#ffffff", "color": "#ffffff",
"index": -1, "index": -1,
"definition": "" "definition": "",
"material": "",
"variant": "",
"stack": None,
"material_brand": "",
"color_name": "",
} }
items.append(item) items.append(item)
if self._items != items:
self.setItems(items) self.setItems(items)
self.modelChanged.emit() self.modelChanged.emit()

View file

@ -0,0 +1,38 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from cura.Machines.Models.BaseMaterialsModel import BaseMaterialsModel
## Model that shows the list of favorite materials.
class FavoriteMaterialsModel(BaseMaterialsModel):
def __init__(self, parent = None):
super().__init__(parent)
self._update()
def _update(self):
if not self._canUpdate():
return
# Get updated list of favorites
self._favorite_ids = self._material_manager.getFavorites()
item_list = []
for root_material_id, container_node in self._available_materials.items():
metadata = container_node.getMetadata()
# Do not include the materials from a to-be-removed package
if bool(metadata.get("removed", False)):
continue
# Only add results for favorite materials
if root_material_id not in self._favorite_ids:
continue
item = self._createMaterialItem(root_material_id, container_node)
item_list.append(item)
# Sort the item list alphabetically by name
item_list = sorted(item_list, key = lambda d: d["brand"].upper())
self.setItems(item_list)

View file

@ -0,0 +1,104 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, Dict, Any, TYPE_CHECKING
from PyQt5.QtCore import QObject, Qt, pyqtProperty, pyqtSignal, pyqtSlot
from UM.Qt.ListModel import ListModel
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):
TitleRole = Qt.UserRole + 1
ContentRole = Qt.UserRole + 2
ActionRole = Qt.UserRole + 3
def __init__(self, application: "CuraApplication", parent: Optional[QObject] = None) -> None:
super().__init__(parent)
self.addRoleName(self.TitleRole, "title")
self.addRoleName(self.ContentRole, "content")
self.addRoleName(self.ActionRole, "action")
self._current_action_index = 0
self._application = application
self._application.initializationFinished.connect(self._initialize)
def _initialize(self) -> None:
self._application.getMachineManager().globalContainerChanged.connect(self._update)
self._update()
currentActionIndexChanged = pyqtSignal()
allFinished = pyqtSignal() # Emitted when all actions have been finished.
@pyqtProperty(int, notify = currentActionIndexChanged)
def currentActionIndex(self) -> int:
return self._current_action_index
@pyqtProperty("QVariantMap", notify = currentActionIndexChanged)
def currentItem(self) -> Optional[Dict[str, Any]]:
if self._current_action_index >= self.count:
return dict()
else:
return self.getItem(self._current_action_index)
@pyqtProperty(bool, notify = currentActionIndexChanged)
def hasMoreActions(self) -> bool:
return self._current_action_index < self.count - 1
@pyqtSlot()
def goToNextAction(self) -> None:
# finish the current item
if "action" in self.currentItem:
self.currentItem["action"].setFinished()
if not self.hasMoreActions:
self.allFinished.emit()
self.reset()
return
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:
self._current_action_index = 0
self.currentActionIndexChanged.emit()
if self.count == 0:
self.allFinished.emit()
def _update(self) -> None:
global_stack = self._application.getMachineManager().activeMachine
if global_stack is None:
self.setItems([])
return
definition_id = global_stack.definition.getId()
first_start_actions = self._application.getMachineActionManager().getFirstStartActions(definition_id)
item_list = []
for item in first_start_actions:
item_list.append({"title": item.label,
"content": item.displayItem,
"action": item,
})
item.reset()
self.setItems(item_list)
self.reset()
__all__ = ["FirstStartMachineActionsModel"]

View file

@ -1,61 +1,38 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from UM.Logger import Logger
from cura.Machines.Models.BaseMaterialsModel import BaseMaterialsModel from cura.Machines.Models.BaseMaterialsModel import BaseMaterialsModel
class GenericMaterialsModel(BaseMaterialsModel): class GenericMaterialsModel(BaseMaterialsModel):
def __init__(self, parent = None): def __init__(self, parent = None):
super().__init__(parent) super().__init__(parent)
from cura.CuraApplication import CuraApplication
self._machine_manager = CuraApplication.getInstance().getMachineManager()
self._extruder_manager = CuraApplication.getInstance().getExtruderManager()
self._material_manager = CuraApplication.getInstance().getMaterialManager()
self._machine_manager.activeStackChanged.connect(self._update) #Update when switching machines.
self._material_manager.materialsUpdated.connect(self._update) #Update when the list of materials changes.
self._update() self._update()
def _update(self): def _update(self):
Logger.log("d", "Updating {model_class_name}.".format(model_class_name = self.__class__.__name__)) if not self._canUpdate():
return
global_stack = self._machine_manager.activeMachine # Get updated list of favorites
if global_stack is None: self._favorite_ids = self._material_manager.getFavorites()
self.setItems([])
return
extruder_position = str(self._extruder_position)
if extruder_position not in global_stack.extruders:
self.setItems([])
return
extruder_stack = global_stack.extruders[extruder_position]
available_material_dict = self._material_manager.getAvailableMaterialsForMachineExtruder(global_stack,
extruder_stack)
if available_material_dict is None:
self.setItems([])
return
item_list = [] item_list = []
for root_material_id, container_node in available_material_dict.items():
metadata = container_node.metadata for root_material_id, container_node in self._available_materials.items():
metadata = container_node.getMetadata()
# Do not include the materials from a to-be-removed package
if bool(metadata.get("removed", False)):
continue
# Only add results for generic materials # Only add results for generic materials
if metadata["brand"].lower() != "generic": if metadata["brand"].lower() != "generic":
continue continue
item = {"root_material_id": root_material_id, item = self._createMaterialItem(root_material_id, container_node)
"id": metadata["id"],
"name": metadata["name"],
"brand": metadata["brand"],
"material": metadata["material"],
"color_name": metadata["color_name"],
"container_node": container_node
}
item_list.append(item) item_list.append(item)
# Sort the item list by material name alphabetically # Sort the item list alphabetically by name
item_list = sorted(item_list, key = lambda d: d["name"].upper()) item_list = sorted(item_list, key = lambda d: d["name"].upper())
self.setItems(item_list) self.setItems(item_list)

View file

@ -0,0 +1,77 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt, QTimer
from UM.Qt.ListModel import ListModel
from UM.i18n import i18nCatalog
from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
from cura.Settings.GlobalStack import GlobalStack
class GlobalStacksModel(ListModel):
NameRole = Qt.UserRole + 1
IdRole = Qt.UserRole + 2
HasRemoteConnectionRole = Qt.UserRole + 3
ConnectionTypeRole = Qt.UserRole + 4
MetaDataRole = Qt.UserRole + 5
DiscoverySourceRole = Qt.UserRole + 6 # For separating local and remote printers in the machine management page
def __init__(self, parent = None) -> None:
super().__init__(parent)
self._catalog = i18nCatalog("cura")
self.addRoleName(self.NameRole, "name")
self.addRoleName(self.IdRole, "id")
self.addRoleName(self.HasRemoteConnectionRole, "hasRemoteConnection")
self.addRoleName(self.MetaDataRole, "metadata")
self.addRoleName(self.DiscoverySourceRole, "discoverySource")
self._change_timer = QTimer()
self._change_timer.setInterval(200)
self._change_timer.setSingleShot(True)
self._change_timer.timeout.connect(self._update)
# Listen to changes
CuraContainerRegistry.getInstance().containerAdded.connect(self._onContainerChanged)
CuraContainerRegistry.getInstance().containerMetaDataChanged.connect(self._onContainerChanged)
CuraContainerRegistry.getInstance().containerRemoved.connect(self._onContainerChanged)
self._updateDelayed()
## Handler for container added/removed events from registry
def _onContainerChanged(self, container) -> None:
# We only need to update when the added / removed container GlobalStack
if isinstance(container, GlobalStack):
self._updateDelayed()
def _updateDelayed(self) -> None:
self._change_timer.start()
def _update(self) -> None:
items = []
container_stacks = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine")
for container_stack in container_stacks:
has_remote_connection = False
for connection_type in container_stack.configuredConnectionTypes:
has_remote_connection |= connection_type in [ConnectionType.NetworkConnection.value,
ConnectionType.CloudConnection.value]
if container_stack.getMetaDataEntry("hidden", False) in ["True", True]:
continue
section_name = "Network enabled printers" if has_remote_connection else "Local printers"
section_name = self._catalog.i18nc("@info:title", section_name)
items.append({"name": container_stack.getMetaDataEntry("group_name", container_stack.getName()),
"id": container_stack.getId(),
"hasRemoteConnection": has_remote_connection,
"metadata": container_stack.getMetaData().copy(),
"discoverySource": section_name})
items.sort(key = lambda i: not i["hasRemoteConnection"])
self.setItems(items)

View file

@ -1,82 +0,0 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.Qt.ListModel import ListModel
from PyQt5.QtCore import Qt
from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.Settings.ContainerStack import ContainerStack
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
#
# This the QML model for the quality management page.
#
class MachineManagementModel(ListModel):
NameRole = Qt.UserRole + 1
IdRole = Qt.UserRole + 2
MetaDataRole = Qt.UserRole + 3
GroupRole = Qt.UserRole + 4
def __init__(self, parent = None):
super().__init__(parent)
self.addRoleName(self.NameRole, "name")
self.addRoleName(self.IdRole, "id")
self.addRoleName(self.MetaDataRole, "metadata")
self.addRoleName(self.GroupRole, "group")
self._local_container_stacks = []
self._network_container_stacks = []
# Listen to changes
ContainerRegistry.getInstance().containerAdded.connect(self._onContainerChanged)
ContainerRegistry.getInstance().containerMetaDataChanged.connect(self._onContainerChanged)
ContainerRegistry.getInstance().containerRemoved.connect(self._onContainerChanged)
self._filter_dict = {}
self._update()
## Handler for container added/removed events from registry
def _onContainerChanged(self, container):
# We only need to update when the added / removed container is a stack.
if isinstance(container, ContainerStack) and container.getMetaDataEntry("type") == "machine":
self._update()
## Private convenience function to reset & repopulate the model.
def _update(self):
items = []
# Get first the network enabled printers
network_filter_printers = {"type": "machine",
"um_network_key": "*",
"hidden": "False"}
self._network_container_stacks = ContainerRegistry.getInstance().findContainerStacks(**network_filter_printers)
self._network_container_stacks.sort(key = lambda i: i.getMetaDataEntry("connect_group_name"))
for container in self._network_container_stacks:
metadata = container.getMetaData().copy()
if container.getBottom():
metadata["definition_name"] = container.getBottom().getName()
items.append({"name": metadata["connect_group_name"],
"id": container.getId(),
"metadata": metadata,
"group": catalog.i18nc("@info:title", "Network enabled printers")})
# Get now the local printers
local_filter_printers = {"type": "machine", "um_network_key": None}
self._local_container_stacks = ContainerRegistry.getInstance().findContainerStacks(**local_filter_printers)
self._local_container_stacks.sort(key = lambda i: i.getName())
for container in self._local_container_stacks:
metadata = container.getMetaData().copy()
if container.getBottom():
metadata["definition_name"] = container.getBottom().getName()
items.append({"name": container.getName(),
"id": container.getId(),
"metadata": metadata,
"group": catalog.i18nc("@info:title", "Local printers")})
self.setItems(items)

View file

@ -0,0 +1,102 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt, pyqtSignal
from UM.Qt.ListModel import ListModel
from cura.Machines.Models.BaseMaterialsModel import BaseMaterialsModel
class MaterialTypesModel(ListModel):
def __init__(self, parent = None):
super().__init__(parent)
self.addRoleName(Qt.UserRole + 1, "name")
self.addRoleName(Qt.UserRole + 2, "brand")
self.addRoleName(Qt.UserRole + 3, "colors")
class MaterialBrandsModel(BaseMaterialsModel):
extruderPositionChanged = pyqtSignal()
def __init__(self, parent = None):
super().__init__(parent)
self.addRoleName(Qt.UserRole + 1, "name")
self.addRoleName(Qt.UserRole + 2, "material_types")
self._update()
def _update(self):
if not self._canUpdate():
return
# Get updated list of favorites
self._favorite_ids = self._material_manager.getFavorites()
brand_item_list = []
brand_group_dict = {}
# Part 1: Generate the entire tree of brands -> material types -> spcific materials
for root_material_id, container_node in self._available_materials.items():
# Do not include the materials from a to-be-removed package
if bool(container_node.getMetaDataEntry("removed", False)):
continue
# Add brands we haven't seen yet to the dict, skipping generics
brand = container_node.getMetaDataEntry("brand", "")
if brand.lower() == "generic":
continue
if brand not in brand_group_dict:
brand_group_dict[brand] = {}
# Add material types we haven't seen yet to the dict
material_type = container_node.getMetaDataEntry("material", "")
if material_type not in brand_group_dict[brand]:
brand_group_dict[brand][material_type] = []
# Now handle the individual materials
item = self._createMaterialItem(root_material_id, container_node)
brand_group_dict[brand][material_type].append(item)
# Part 2: Organize the tree into models
#
# Normally, the structure of the menu looks like this:
# Brand -> Material Type -> Specific Material
#
# To illustrate, a branded material menu may look like this:
# Ultimaker ┳ PLA ┳ Yellow PLA
# ┃ ┣ Black PLA
# ┃ ┗ ...
# ┃
# ┗ ABS ┳ White ABS
# ┗ ...
for brand, material_dict in brand_group_dict.items():
material_type_item_list = []
brand_item = {
"name": brand,
"material_types": MaterialTypesModel(self)
}
for material_type, material_list in material_dict.items():
material_type_item = {
"name": material_type,
"brand": brand,
"colors": BaseMaterialsModel(self)
}
material_type_item["colors"].clear()
# Sort materials by name
material_list = sorted(material_list, key = lambda x: x["name"].upper())
material_type_item["colors"].setItems(material_list)
material_type_item_list.append(material_type_item)
# Sort material type by name
material_type_item_list = sorted(material_type_item_list, key = lambda x: x["name"].upper())
brand_item["material_types"].setItems(material_type_item_list)
brand_item_list.append(brand_item)
# Sort brand by name
brand_item_list = sorted(brand_item_list, key = lambda x: x["name"].upper())
self.setItems(brand_item_list)

View file

@ -1,104 +0,0 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt
from UM.Logger import Logger
from UM.Qt.ListModel import ListModel
#
# This model is for the Material management page.
#
class MaterialManagementModel(ListModel):
RootMaterialIdRole = Qt.UserRole + 1
DisplayNameRole = Qt.UserRole + 2
BrandRole = Qt.UserRole + 3
MaterialTypeRole = Qt.UserRole + 4
ColorNameRole = Qt.UserRole + 5
ColorCodeRole = Qt.UserRole + 6
ContainerNodeRole = Qt.UserRole + 7
ContainerIdRole = Qt.UserRole + 8
DescriptionRole = Qt.UserRole + 9
AdhesionInfoRole = Qt.UserRole + 10
ApproximateDiameterRole = Qt.UserRole + 11
GuidRole = Qt.UserRole + 12
DensityRole = Qt.UserRole + 13
DiameterRole = Qt.UserRole + 14
IsReadOnlyRole = Qt.UserRole + 15
def __init__(self, parent = None):
super().__init__(parent)
self.addRoleName(self.RootMaterialIdRole, "root_material_id")
self.addRoleName(self.DisplayNameRole, "name")
self.addRoleName(self.BrandRole, "brand")
self.addRoleName(self.MaterialTypeRole, "material")
self.addRoleName(self.ColorNameRole, "color_name")
self.addRoleName(self.ColorCodeRole, "color_code")
self.addRoleName(self.ContainerNodeRole, "container_node")
self.addRoleName(self.ContainerIdRole, "container_id")
self.addRoleName(self.DescriptionRole, "description")
self.addRoleName(self.AdhesionInfoRole, "adhesion_info")
self.addRoleName(self.ApproximateDiameterRole, "approximate_diameter")
self.addRoleName(self.GuidRole, "guid")
self.addRoleName(self.DensityRole, "density")
self.addRoleName(self.DiameterRole, "diameter")
self.addRoleName(self.IsReadOnlyRole, "is_read_only")
from cura.CuraApplication import CuraApplication
self._container_registry = CuraApplication.getInstance().getContainerRegistry()
self._machine_manager = CuraApplication.getInstance().getMachineManager()
self._extruder_manager = CuraApplication.getInstance().getExtruderManager()
self._material_manager = CuraApplication.getInstance().getMaterialManager()
self._machine_manager.globalContainerChanged.connect(self._update)
self._extruder_manager.activeExtruderChanged.connect(self._update)
self._material_manager.materialsUpdated.connect(self._update)
self._update()
def _update(self):
Logger.log("d", "Updating {model_class_name}.".format(model_class_name = self.__class__.__name__))
global_stack = self._machine_manager.activeMachine
if global_stack is None:
self.setItems([])
return
active_extruder_stack = self._machine_manager.activeStack
available_material_dict = self._material_manager.getAvailableMaterialsForMachineExtruder(global_stack,
active_extruder_stack)
if available_material_dict is None:
self.setItems([])
return
material_list = []
for root_material_id, container_node in available_material_dict.items():
keys_to_fetch = ("name",
"brand",
"material",
"color_name",
"color_code",
"description",
"adhesion_info",
"approximate_diameter",)
item = {"root_material_id": container_node.metadata["base_file"],
"container_node": container_node,
"guid": container_node.metadata["GUID"],
"container_id": container_node.metadata["id"],
"density": container_node.metadata.get("properties", {}).get("density", ""),
"diameter": container_node.metadata.get("properties", {}).get("diameter", ""),
"is_read_only": self._container_registry.isReadOnly(container_node.metadata["id"]),
}
for key in keys_to_fetch:
item[key] = container_node.metadata.get(key, "")
material_list.append(item)
material_list = sorted(material_list, key = lambda k: (k["brand"].upper(), k["name"].upper()))
self.setItems(material_list)

View file

@ -4,6 +4,7 @@
from PyQt5.QtCore import QTimer, pyqtSignal, pyqtProperty from PyQt5.QtCore import QTimer, pyqtSignal, pyqtProperty
from UM.Application import Application from UM.Application import Application
from UM.Scene.Camera import Camera
from UM.Scene.Selection import Selection from UM.Scene.Selection import Selection
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
@ -34,6 +35,7 @@ class MultiBuildPlateModel(ListModel):
self._active_build_plate = -1 self._active_build_plate = -1
def setMaxBuildPlate(self, max_build_plate): def setMaxBuildPlate(self, max_build_plate):
if self._max_build_plate != max_build_plate:
self._max_build_plate = max_build_plate self._max_build_plate = max_build_plate
self.maxBuildPlateChanged.emit() self.maxBuildPlateChanged.emit()
@ -43,6 +45,7 @@ class MultiBuildPlateModel(ListModel):
return self._max_build_plate return self._max_build_plate
def setActiveBuildPlate(self, nr): def setActiveBuildPlate(self, nr):
if self._active_build_plate != nr:
self._active_build_plate = nr self._active_build_plate = nr
self.activeBuildPlateChanged.emit() self.activeBuildPlateChanged.emit()
@ -51,6 +54,7 @@ class MultiBuildPlateModel(ListModel):
return self._active_build_plate return self._active_build_plate
def _updateSelectedObjectBuildPlateNumbersDelayed(self, *args): def _updateSelectedObjectBuildPlateNumbersDelayed(self, *args):
if not isinstance(args[0], Camera):
self._update_timer.start() self._update_timer.start()
def _updateSelectedObjectBuildPlateNumbers(self, *args): def _updateSelectedObjectBuildPlateNumbers(self, *args):

View file

@ -8,6 +8,8 @@ from UM.Logger import Logger
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
from UM.Util import parseBool from UM.Util import parseBool
from cura.Machines.VariantType import VariantType
class NozzleModel(ListModel): class NozzleModel(ListModel):
IdRole = Qt.UserRole + 1 IdRole = Qt.UserRole + 1
@ -31,8 +33,6 @@ class NozzleModel(ListModel):
def _update(self): def _update(self):
Logger.log("d", "Updating {model_class_name}.".format(model_class_name = self.__class__.__name__)) Logger.log("d", "Updating {model_class_name}.".format(model_class_name = self.__class__.__name__))
self.items.clear()
global_stack = self._machine_manager.activeMachine global_stack = self._machine_manager.activeMachine
if global_stack is None: if global_stack is None:
self.setItems([]) self.setItems([])
@ -43,7 +43,6 @@ class NozzleModel(ListModel):
self.setItems([]) self.setItems([])
return return
from cura.Machines.VariantManager import VariantType
variant_node_dict = self._variant_manager.getVariantNodes(global_stack, VariantType.NOZZLE) variant_node_dict = self._variant_manager.getVariantNodes(global_stack, VariantType.NOZZLE)
if not variant_node_dict: if not variant_node_dict:
self.setItems([]) self.setItems([])

View file

@ -1,11 +1,12 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt, QTimer
from UM.Application import Application from UM.Application import Application
from UM.Logger import Logger from UM.Logger import Logger
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
from UM.Settings.SettingFunction import SettingFunction
from cura.Machines.QualityManager import QualityGroup from cura.Machines.QualityManager import QualityGroup
@ -21,6 +22,7 @@ class QualityProfilesDropDownMenuModel(ListModel):
AvailableRole = Qt.UserRole + 5 AvailableRole = Qt.UserRole + 5
QualityGroupRole = Qt.UserRole + 6 QualityGroupRole = Qt.UserRole + 6
QualityChangesGroupRole = Qt.UserRole + 7 QualityChangesGroupRole = Qt.UserRole + 7
IsExperimentalRole = Qt.UserRole + 8
def __init__(self, parent = None): def __init__(self, parent = None):
super().__init__(parent) super().__init__(parent)
@ -32,19 +34,28 @@ class QualityProfilesDropDownMenuModel(ListModel):
self.addRoleName(self.AvailableRole, "available") #Whether the quality profile is available in our current nozzle + material. self.addRoleName(self.AvailableRole, "available") #Whether the quality profile is available in our current nozzle + material.
self.addRoleName(self.QualityGroupRole, "quality_group") self.addRoleName(self.QualityGroupRole, "quality_group")
self.addRoleName(self.QualityChangesGroupRole, "quality_changes_group") self.addRoleName(self.QualityChangesGroupRole, "quality_changes_group")
self.addRoleName(self.IsExperimentalRole, "is_experimental")
self._application = Application.getInstance() self._application = Application.getInstance()
self._machine_manager = self._application.getMachineManager() self._machine_manager = self._application.getMachineManager()
self._quality_manager = Application.getInstance().getQualityManager() self._quality_manager = Application.getInstance().getQualityManager()
self._application.globalContainerStackChanged.connect(self._update) self._application.globalContainerStackChanged.connect(self._onChange)
self._machine_manager.activeQualityGroupChanged.connect(self._update) self._machine_manager.activeQualityGroupChanged.connect(self._onChange)
self._machine_manager.extruderChanged.connect(self._update) self._machine_manager.extruderChanged.connect(self._onChange)
self._quality_manager.qualitiesUpdated.connect(self._update) self._quality_manager.qualitiesUpdated.connect(self._onChange)
self._layer_height_unit = "" # This is cached self._layer_height_unit = "" # This is cached
self._update() self._update_timer = QTimer() # type: QTimer
self._update_timer.setInterval(100)
self._update_timer.setSingleShot(True)
self._update_timer.timeout.connect(self._update)
self._onChange()
def _onChange(self) -> None:
self._update_timer.start()
def _update(self): def _update(self):
Logger.log("d", "Updating {model_class_name}.".format(model_class_name = self.__class__.__name__)) Logger.log("d", "Updating {model_class_name}.".format(model_class_name = self.__class__.__name__))
@ -74,7 +85,8 @@ class QualityProfilesDropDownMenuModel(ListModel):
"layer_height": layer_height, "layer_height": layer_height,
"layer_height_unit": self._layer_height_unit, "layer_height_unit": self._layer_height_unit,
"available": quality_group.is_available, "available": quality_group.is_available,
"quality_group": quality_group} "quality_group": quality_group,
"is_experimental": quality_group.is_experimental}
item_list.append(item) item_list.append(item)
@ -83,7 +95,7 @@ class QualityProfilesDropDownMenuModel(ListModel):
self.setItems(item_list) self.setItems(item_list)
def _fetchLayerHeight(self, quality_group: "QualityGroup"): def _fetchLayerHeight(self, quality_group: "QualityGroup") -> float:
global_stack = self._machine_manager.activeMachine global_stack = self._machine_manager.activeMachine
if not self._layer_height_unit: if not self._layer_height_unit:
unit = global_stack.definition.getProperty("layer_height", "unit") unit = global_stack.definition.getProperty("layer_height", "unit")
@ -94,14 +106,20 @@ class QualityProfilesDropDownMenuModel(ListModel):
default_layer_height = global_stack.definition.getProperty("layer_height", "value") default_layer_height = global_stack.definition.getProperty("layer_height", "value")
# Get layer_height from the quality profile for the GlobalStack # Get layer_height from the quality profile for the GlobalStack
if quality_group.node_for_global is None:
return float(default_layer_height)
container = quality_group.node_for_global.getContainer() container = quality_group.node_for_global.getContainer()
layer_height = default_layer_height layer_height = default_layer_height
if container.hasProperty("layer_height", "value"): if container and container.hasProperty("layer_height", "value"):
layer_height = container.getProperty("layer_height", "value") layer_height = container.getProperty("layer_height", "value")
else: else:
# Look for layer_height in the GlobalStack from material -> definition # Look for layer_height in the GlobalStack from material -> definition
container = global_stack.definition container = global_stack.definition
if container.hasProperty("layer_height", "value"): if container and container.hasProperty("layer_height", "value"):
layer_height = container.getProperty("layer_height", "value") layer_height = container.getProperty("layer_height", "value")
if isinstance(layer_height, SettingFunction):
layer_height = layer_height(global_stack)
return float(layer_height) return float(layer_height)

View file

@ -1,135 +1,115 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional from typing import Optional, List
import os
import urllib.parse
from configparser import ConfigParser
from PyQt5.QtCore import pyqtProperty, Qt, pyqtSignal, pyqtSlot from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
from UM.Logger import Logger from UM.Logger import Logger
from UM.Qt.ListModel import ListModel
from UM.Preferences import Preferences from UM.Preferences import Preferences
from UM.Resources import Resources from UM.Resources import Resources
from UM.MimeTypeDatabase import MimeTypeDatabase, MimeTypeNotFoundError
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from cura.Settings.SettingVisibilityPreset import SettingVisibilityPreset
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
class SettingVisibilityPresetsModel(ListModel): class SettingVisibilityPresetsModel(QObject):
IdRole = Qt.UserRole + 1 onItemsChanged = pyqtSignal()
NameRole = Qt.UserRole + 2 activePresetChanged = pyqtSignal()
SettingsRole = Qt.UserRole + 3
def __init__(self, parent = None): def __init__(self, preferences: Preferences, parent = None) -> None:
super().__init__(parent) super().__init__(parent)
self.addRoleName(self.IdRole, "id")
self.addRoleName(self.NameRole, "name") self._items = [] # type: List[SettingVisibilityPreset]
self.addRoleName(self.SettingsRole, "settings") self._custom_preset = SettingVisibilityPreset(preset_id = "custom", name = "Custom selection", weight = -100)
self._populate() self._populate()
basic_item = self.items[1]
basic_visibile_settings = ";".join(basic_item["settings"])
self._preferences = Preferences.getInstance() basic_item = self.getVisibilityPresetById("basic")
if basic_item is not None:
basic_visibile_settings = ";".join(basic_item.settings)
else:
Logger.log("w", "Unable to find the basic visiblity preset.")
basic_visibile_settings = ""
self._preferences = preferences
# Preference to store which preset is currently selected # Preference to store which preset is currently selected
self._preferences.addPreference("cura/active_setting_visibility_preset", "basic") self._preferences.addPreference("cura/active_setting_visibility_preset", "basic")
# Preference that stores the "custom" set so it can always be restored (even after a restart) # Preference that stores the "custom" set so it can always be restored (even after a restart)
self._preferences.addPreference("cura/custom_visible_settings", basic_visibile_settings) self._preferences.addPreference("cura/custom_visible_settings", basic_visibile_settings)
self._preferences.preferenceChanged.connect(self._onPreferencesChanged) self._preferences.preferenceChanged.connect(self._onPreferencesChanged)
self._active_preset_item = self._getItem(self._preferences.getValue("cura/active_setting_visibility_preset")) self._active_preset_item = self.getVisibilityPresetById(self._preferences.getValue("cura/active_setting_visibility_preset"))
# Initialize visible settings if it is not done yet # Initialize visible settings if it is not done yet
visible_settings = self._preferences.getValue("general/visible_settings") visible_settings = self._preferences.getValue("general/visible_settings")
if not visible_settings: if not visible_settings:
self._preferences.setValue("general/visible_settings", ";".join(self._active_preset_item["settings"])) new_visible_settings = self._active_preset_item.settings if self._active_preset_item is not None else []
self._preferences.setValue("general/visible_settings", ";".join(new_visible_settings))
else: else:
self._onPreferencesChanged("general/visible_settings") self._onPreferencesChanged("general/visible_settings")
self.activePresetChanged.emit() self.activePresetChanged.emit()
def _getItem(self, item_id: str) -> Optional[dict]: def getVisibilityPresetById(self, item_id: str) -> Optional[SettingVisibilityPreset]:
result = None result = None
for item in self.items: for item in self._items:
if item["id"] == item_id: if item.presetId == item_id:
result = item result = item
break break
return result return result
def _populate(self): def _populate(self) -> None:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
items = [] items = [] # type: List[SettingVisibilityPreset]
items.append(self._custom_preset)
for file_path in Resources.getAllResourcesOfType(CuraApplication.ResourceTypes.SettingVisibilityPreset): for file_path in Resources.getAllResourcesOfType(CuraApplication.ResourceTypes.SettingVisibilityPreset):
setting_visibility_preset = SettingVisibilityPreset()
try: try:
mime_type = MimeTypeDatabase.getMimeTypeForFile(file_path) setting_visibility_preset.loadFromFile(file_path)
except MimeTypeNotFoundError:
Logger.log("e", "Could not determine mime type of file %s", file_path)
continue
item_id = urllib.parse.unquote_plus(mime_type.stripExtension(os.path.basename(file_path)))
if not os.path.isfile(file_path):
Logger.log("e", "[%s] is not a file", file_path)
continue
parser = ConfigParser(allow_no_value = True) # accept options without any value,
try:
parser.read([file_path])
if not parser.has_option("general", "name") or not parser.has_option("general", "weight"):
continue
settings = []
for section in parser.sections():
if section == 'general':
continue
settings.append(section)
for option in parser[section].keys():
settings.append(option)
items.append({
"id": item_id,
"name": catalog.i18nc("@action:inmenu", parser["general"]["name"]),
"weight": parser["general"]["weight"],
"settings": settings,
})
except Exception: except Exception:
Logger.logException("e", "Failed to load setting preset %s", file_path) Logger.logException("e", "Failed to load setting preset %s", file_path)
items.sort(key = lambda k: (int(k["weight"]), k["id"])) items.append(setting_visibility_preset)
# Put "custom" at the top
items.insert(0, {"id": "custom", # Sort them on weight (and if that fails, use ID)
"name": "Custom selection", items.sort(key = lambda k: (int(k.weight), k.presetId))
"weight": -100,
"settings": []})
self.setItems(items) self.setItems(items)
@pyqtProperty("QVariantList", notify = onItemsChanged)
def items(self) -> List[SettingVisibilityPreset]:
return self._items
def setItems(self, items: List[SettingVisibilityPreset]) -> None:
if self._items != items:
self._items = items
self.onItemsChanged.emit()
@pyqtSlot(str) @pyqtSlot(str)
def setActivePreset(self, preset_id: str): def setActivePreset(self, preset_id: str) -> None:
if preset_id == self._active_preset_item["id"]: if self._active_preset_item is not None and preset_id == self._active_preset_item.presetId:
Logger.log("d", "Same setting visibility preset [%s] selected, do nothing.", preset_id) Logger.log("d", "Same setting visibility preset [%s] selected, do nothing.", preset_id)
return return
preset_item = None preset_item = self.getVisibilityPresetById(preset_id)
for item in self.items:
if item["id"] == preset_id:
preset_item = item
break
if preset_item is None: if preset_item is None:
Logger.log("w", "Tried to set active preset to unknown id [%s]", preset_id) Logger.log("w", "Tried to set active preset to unknown id [%s]", preset_id)
return return
need_to_save_to_custom = self._active_preset_item["id"] == "custom" and preset_id != "custom" need_to_save_to_custom = self._active_preset_item is None or (self._active_preset_item.presetId == "custom" and preset_id != "custom")
if need_to_save_to_custom: if need_to_save_to_custom:
# Save the current visibility settings to custom # Save the current visibility settings to custom
current_visibility_string = self._preferences.getValue("general/visible_settings") current_visibility_string = self._preferences.getValue("general/visible_settings")
if current_visibility_string: if current_visibility_string:
self._preferences.setValue("cura/custom_visible_settings", current_visibility_string) self._preferences.setValue("cura/custom_visible_settings", current_visibility_string)
new_visibility_string = ";".join(preset_item["settings"]) new_visibility_string = ";".join(preset_item.settings)
if preset_id == "custom": if preset_id == "custom":
# Get settings from the stored custom data # Get settings from the stored custom data
new_visibility_string = self._preferences.getValue("cura/custom_visible_settings") new_visibility_string = self._preferences.getValue("cura/custom_visible_settings")
@ -141,13 +121,13 @@ class SettingVisibilityPresetsModel(ListModel):
self._active_preset_item = preset_item self._active_preset_item = preset_item
self.activePresetChanged.emit() self.activePresetChanged.emit()
activePresetChanged = pyqtSignal()
@pyqtProperty(str, notify = activePresetChanged) @pyqtProperty(str, notify = activePresetChanged)
def activePreset(self) -> str: def activePreset(self) -> str:
return self._active_preset_item["id"] if self._active_preset_item is not None:
return self._active_preset_item.presetId
return ""
def _onPreferencesChanged(self, name: str): def _onPreferencesChanged(self, name: str) -> None:
if name != "general/visible_settings": if name != "general/visible_settings":
return return
@ -158,25 +138,31 @@ class SettingVisibilityPresetsModel(ListModel):
visibility_set = set(visibility_string.split(";")) visibility_set = set(visibility_string.split(";"))
matching_preset_item = None matching_preset_item = None
for item in self.items: for item in self._items:
if item["id"] == "custom": if item.presetId == "custom":
continue continue
if set(item["settings"]) == visibility_set: if set(item.settings) == visibility_set:
matching_preset_item = item matching_preset_item = item
break break
item_to_set = self._active_preset_item item_to_set = self._active_preset_item
if matching_preset_item is None: if matching_preset_item is None:
# The new visibility setup is "custom" should be custom # The new visibility setup is "custom" should be custom
if self._active_preset_item["id"] == "custom": if self._active_preset_item is None or self._active_preset_item.presetId == "custom":
# We are already in custom, just save the settings # We are already in custom, just save the settings
self._preferences.setValue("cura/custom_visible_settings", visibility_string) self._preferences.setValue("cura/custom_visible_settings", visibility_string)
else: else:
item_to_set = self.items[0] # 0 is custom # We need to move to custom preset.
item_to_set = self.getVisibilityPresetById("custom")
else: else:
item_to_set = matching_preset_item item_to_set = matching_preset_item
if self._active_preset_item is None or self._active_preset_item["id"] != item_to_set["id"]: # If we didn't find a matching preset, fallback to custom.
if item_to_set is None:
item_to_set = self._custom_preset
if self._active_preset_item is None or self._active_preset_item.presetId != item_to_set.presetId:
self._active_preset_item = item_to_set self._active_preset_item = item_to_set
self._preferences.setValue("cura/active_setting_visibility_preset", self._active_preset_item["id"]) if self._active_preset_item is not None:
self._preferences.setValue("cura/active_setting_visibility_preset", self._active_preset_item.presetId)
self.activePresetChanged.emit() self.activePresetChanged.emit()

View file

@ -1,15 +1,16 @@
from UM.Qt.ListModel import ListModel # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import os
from collections import OrderedDict
from PyQt5.QtCore import pyqtSlot, Qt from PyQt5.QtCore import pyqtSlot, Qt
from UM.Application import Application from UM.Application import Application
from cura.Settings.ExtruderManager import ExtruderManager
from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from UM.Settings.SettingFunction import SettingFunction from UM.Settings.SettingFunction import SettingFunction
from UM.Settings.PropertyEvaluationContext import PropertyEvaluationContext from UM.Qt.ListModel import ListModel
from collections import OrderedDict
import os
class UserChangesModel(ListModel): class UserChangesModel(ListModel):
@ -38,12 +39,18 @@ class UserChangesModel(ListModel):
self._update() self._update()
def _update(self): def _update(self):
application = Application.getInstance()
machine_manager = application.getMachineManager()
cura_formula_functions = application.getCuraFormulaFunctions()
item_dict = OrderedDict() item_dict = OrderedDict()
item_list = [] item_list = []
global_stack = Application.getInstance().getGlobalContainerStack() global_stack = machine_manager.activeMachine
if not global_stack: if not global_stack:
return return
stacks = ExtruderManager.getInstance().getActiveGlobalAndExtruderStacks()
stacks = [global_stack]
stacks.extend(global_stack.extruders.values())
# Check if the definition container has a translation file and ensure it's loaded. # Check if the definition container has a translation file and ensure it's loaded.
definition = global_stack.getBottom() definition = global_stack.getBottom()
@ -69,13 +76,7 @@ class UserChangesModel(ListModel):
# Override "getExtruderValue" with "getDefaultExtruderValue" so we can get the default values # Override "getExtruderValue" with "getDefaultExtruderValue" so we can get the default values
user_changes = containers.pop(0) user_changes = containers.pop(0)
default_value_resolve_context = PropertyEvaluationContext(stack) default_value_resolve_context = cura_formula_functions.createContextForDefaultValueEvaluation(stack)
default_value_resolve_context.context["evaluate_from_container_index"] = 1 # skip the user settings container
default_value_resolve_context.context["override_operators"] = {
"extruderValue": ExtruderManager.getDefaultExtruderValue,
"extruderValues": ExtruderManager.getDefaultExtruderValues,
"resolveOrValue": ExtruderManager.getDefaultResolveOrValue
}
for setting_key in user_changes.getAllKeys(): for setting_key in user_changes.getAllKeys():
original_value = None original_value = None

View file

@ -1,27 +1,32 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import TYPE_CHECKING
from UM.Application import Application from UM.Application import Application
from UM.ConfigurationErrorMessage import ConfigurationErrorMessage from UM.ConfigurationErrorMessage import ConfigurationErrorMessage
from .QualityGroup import QualityGroup from .QualityGroup import QualityGroup
if TYPE_CHECKING:
from cura.Machines.QualityNode import QualityNode
class QualityChangesGroup(QualityGroup): class QualityChangesGroup(QualityGroup):
def __init__(self, name: str, quality_type: str, parent = None): def __init__(self, name: str, quality_type: str, parent = None) -> None:
super().__init__(name, quality_type, parent) super().__init__(name, quality_type, parent)
self._container_registry = Application.getInstance().getContainerRegistry() self._container_registry = Application.getInstance().getContainerRegistry()
def addNode(self, node: "QualityNode"): def addNode(self, node: "QualityNode") -> None:
extruder_position = node.metadata.get("position") extruder_position = node.getMetaDataEntry("position")
if extruder_position is None and self.node_for_global is not None or extruder_position in self.nodes_for_extruders: #We would be overwriting another node. if extruder_position is None and self.node_for_global is not None or extruder_position in self.nodes_for_extruders: #We would be overwriting another node.
ConfigurationErrorMessage.getInstance().addFaultyContainers(node.metadata["id"]) ConfigurationErrorMessage.getInstance().addFaultyContainers(node.getMetaDataEntry("id"))
return return
if extruder_position is None: #Then we're a global quality changes profile. if extruder_position is None: # Then we're a global quality changes profile.
self.node_for_global = node self.node_for_global = node
else: #This is an extruder's quality changes profile. else: # This is an extruder's quality changes profile.
self.nodes_for_extruders[extruder_position] = node self.nodes_for_extruders[extruder_position] = node
def __str__(self) -> str: def __str__(self) -> str:

View file

@ -1,10 +1,14 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Dict, Optional, List from typing import Dict, Optional, List, Set
from PyQt5.QtCore import QObject, pyqtSlot from PyQt5.QtCore import QObject, pyqtSlot
from UM.Util import parseBool
from cura.Machines.ContainerNode import ContainerNode
# #
# A QualityGroup represents a group of containers that must be applied to each ContainerStack when it's used. # A QualityGroup represents a group of containers that must be applied to each ContainerStack when it's used.
@ -21,30 +25,47 @@ from PyQt5.QtCore import QObject, pyqtSlot
# #
class QualityGroup(QObject): class QualityGroup(QObject):
def __init__(self, name: str, quality_type: str, parent = None): def __init__(self, name: str, quality_type: str, parent = None) -> None:
super().__init__(parent) super().__init__(parent)
self.name = name self.name = name
self.node_for_global = None # type: Optional["QualityGroup"] self.node_for_global = None # type: Optional[ContainerNode]
self.nodes_for_extruders = {} # type: Dict[int, "QualityGroup"] self.nodes_for_extruders = {} # type: Dict[int, ContainerNode]
self.quality_type = quality_type self.quality_type = quality_type
self.is_available = False self.is_available = False
self.is_experimental = False
@pyqtSlot(result = str) @pyqtSlot(result = str)
def getName(self) -> str: def getName(self) -> str:
return self.name return self.name
def getAllKeys(self) -> set: def getAllKeys(self) -> Set[str]:
result = set() result = set() # type: Set[str]
for node in [self.node_for_global] + list(self.nodes_for_extruders.values()): for node in [self.node_for_global] + list(self.nodes_for_extruders.values()):
if node is None: if node is None:
continue continue
result.update(node.getContainer().getAllKeys()) container = node.getContainer()
if container:
result.update(container.getAllKeys())
return result return result
def getAllNodes(self) -> List["QualityGroup"]: def getAllNodes(self) -> List[ContainerNode]:
result = [] result = []
if self.node_for_global is not None: if self.node_for_global is not None:
result.append(self.node_for_global) result.append(self.node_for_global)
for extruder_node in self.nodes_for_extruders.values(): for extruder_node in self.nodes_for_extruders.values():
result.append(extruder_node) result.append(extruder_node)
return result return result
def setGlobalNode(self, node: "ContainerNode") -> None:
self.node_for_global = node
# Update is_experimental flag
is_experimental = parseBool(node.getMetaDataEntry("is_experimental", False))
self.is_experimental |= is_experimental
def setExtruderNode(self, position: int, node: "ContainerNode") -> None:
self.nodes_for_extruders[position] = node
# Update is_experimental flag
is_experimental = parseBool(node.getMetaDataEntry("is_experimental", False))
self.is_experimental |= is_experimental

View file

@ -1,11 +1,10 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional, cast, Dict, List, Set
from PyQt5.QtCore import QObject, QTimer, pyqtSignal, pyqtSlot from PyQt5.QtCore import QObject, QTimer, pyqtSignal, pyqtSlot
from UM.Application import Application
from UM.ConfigurationErrorMessage import ConfigurationErrorMessage from UM.ConfigurationErrorMessage import ConfigurationErrorMessage
from UM.Logger import Logger from UM.Logger import Logger
from UM.Util import parseBool from UM.Util import parseBool
@ -17,9 +16,10 @@ from .QualityGroup import QualityGroup
from .QualityNode import QualityNode from .QualityNode import QualityNode
if TYPE_CHECKING: if TYPE_CHECKING:
from UM.Settings.DefinitionContainer import DefinitionContainer from UM.Settings.Interfaces import DefinitionContainerInterface
from cura.Settings.GlobalStack import GlobalStack from cura.Settings.GlobalStack import GlobalStack
from .QualityChangesGroup import QualityChangesGroup from .QualityChangesGroup import QualityChangesGroup
from cura.CuraApplication import CuraApplication
# #
@ -36,17 +36,20 @@ class QualityManager(QObject):
qualitiesUpdated = pyqtSignal() qualitiesUpdated = pyqtSignal()
def __init__(self, container_registry, parent = None): def __init__(self, application: "CuraApplication", parent = None) -> None:
super().__init__(parent) super().__init__(parent)
self._application = Application.getInstance() self._application = application
self._material_manager = self._application.getMaterialManager() self._material_manager = self._application.getMaterialManager()
self._container_registry = container_registry self._container_registry = self._application.getContainerRegistry()
self._empty_quality_container = self._application.empty_quality_container self._empty_quality_container = self._application.empty_quality_container
self._empty_quality_changes_container = self._application.empty_quality_changes_container self._empty_quality_changes_container = self._application.empty_quality_changes_container
self._machine_variant_material_quality_type_to_quality_dict = {} # for quality lookup # For quality lookup
self._machine_quality_type_to_quality_changes_dict = {} # for quality_changes lookup self._machine_nozzle_buildplate_material_quality_type_to_quality_dict = {} # type: Dict[str, QualityNode]
# For quality_changes lookup
self._machine_quality_type_to_quality_changes_dict = {} # type: Dict[str, QualityNode]
self._default_machine_definition_id = "fdmprinter" self._default_machine_definition_id = "fdmprinter"
@ -62,12 +65,12 @@ class QualityManager(QObject):
self._update_timer.setSingleShot(True) self._update_timer.setSingleShot(True)
self._update_timer.timeout.connect(self._updateMaps) self._update_timer.timeout.connect(self._updateMaps)
def initialize(self): def initialize(self) -> None:
# Initialize the lookup tree for quality profiles with following structure: # Initialize the lookup tree for quality profiles with following structure:
# <machine> -> <variant> -> <material> # <machine> -> <nozzle> -> <buildplate> -> <material>
# -> <material> # <machine> -> <material>
self._machine_variant_material_quality_type_to_quality_dict = {} # for quality lookup self._machine_nozzle_buildplate_material_quality_type_to_quality_dict = {} # for quality lookup
self._machine_quality_type_to_quality_changes_dict = {} # for quality_changes lookup self._machine_quality_type_to_quality_changes_dict = {} # for quality_changes lookup
quality_metadata_list = self._container_registry.findContainersMetadata(type = "quality") quality_metadata_list = self._container_registry.findContainersMetadata(type = "quality")
@ -79,53 +82,41 @@ class QualityManager(QObject):
quality_type = metadata["quality_type"] quality_type = metadata["quality_type"]
root_material_id = metadata.get("material") root_material_id = metadata.get("material")
variant_name = metadata.get("variant") nozzle_name = metadata.get("variant")
buildplate_name = metadata.get("buildplate")
is_global_quality = metadata.get("global_quality", False) is_global_quality = metadata.get("global_quality", False)
is_global_quality = is_global_quality or (root_material_id is None and variant_name is None) is_global_quality = is_global_quality or (root_material_id is None and nozzle_name is None and buildplate_name is None)
# Sanity check: material+variant and is_global_quality cannot be present at the same time # Sanity check: material+variant and is_global_quality cannot be present at the same time
if is_global_quality and (root_material_id or variant_name): if is_global_quality and (root_material_id or nozzle_name):
ConfigurationErrorMessage.getInstance().addFaultyContainers(metadata["id"]) ConfigurationErrorMessage.getInstance().addFaultyContainers(metadata["id"])
continue continue
if definition_id not in self._machine_variant_material_quality_type_to_quality_dict: if definition_id not in self._machine_nozzle_buildplate_material_quality_type_to_quality_dict:
self._machine_variant_material_quality_type_to_quality_dict[definition_id] = QualityNode() self._machine_nozzle_buildplate_material_quality_type_to_quality_dict[definition_id] = QualityNode()
machine_node = self._machine_variant_material_quality_type_to_quality_dict[definition_id] machine_node = cast(QualityNode, self._machine_nozzle_buildplate_material_quality_type_to_quality_dict[definition_id])
if is_global_quality: if is_global_quality:
# For global qualities, save data in the machine node # For global qualities, save data in the machine node
machine_node.addQualityMetadata(quality_type, metadata) machine_node.addQualityMetadata(quality_type, metadata)
continue continue
if variant_name is not None: current_node = machine_node
# If variant_name is specified in the quality/quality_changes profile, check if material is specified, intermediate_node_info_list = [nozzle_name, buildplate_name, root_material_id]
# too. current_intermediate_node_info_idx = 0
if variant_name not in machine_node.children_map:
machine_node.children_map[variant_name] = QualityNode()
variant_node = machine_node.children_map[variant_name]
if root_material_id is None: while current_intermediate_node_info_idx < len(intermediate_node_info_list):
# If only variant_name is specified but material is not, add the quality/quality_changes metadata node_name = intermediate_node_info_list[current_intermediate_node_info_idx]
# into the current variant node. if node_name is not None:
variant_node.addQualityMetadata(quality_type, metadata) # There is specific information, update the current node to go deeper so we can add this quality
else: # at the most specific branch in the lookup tree.
# If only variant_name and material are both specified, go one level deeper: create a material node if node_name not in current_node.children_map:
# under the current variant node, and then add the quality/quality_changes metadata into the current_node.children_map[node_name] = QualityNode()
# material node. current_node = cast(QualityNode, current_node.children_map[node_name])
if root_material_id not in variant_node.children_map:
variant_node.children_map[root_material_id] = QualityNode()
material_node = variant_node.children_map[root_material_id]
material_node.addQualityMetadata(quality_type, metadata) current_intermediate_node_info_idx += 1
else: current_node.addQualityMetadata(quality_type, metadata)
# If variant_name is not specified, check if material is specified.
if root_material_id is not None:
if root_material_id not in machine_node.children_map:
machine_node.children_map[root_material_id] = QualityNode()
material_node = machine_node.children_map[root_material_id]
material_node.addQualityMetadata(quality_type, metadata)
# Initialize the lookup tree for quality_changes profiles with following structure: # Initialize the lookup tree for quality_changes profiles with following structure:
# <machine> -> <quality_type> -> <name> # <machine> -> <quality_type> -> <name>
@ -145,13 +136,13 @@ class QualityManager(QObject):
Logger.log("d", "Lookup tables updated.") Logger.log("d", "Lookup tables updated.")
self.qualitiesUpdated.emit() self.qualitiesUpdated.emit()
def _updateMaps(self): def _updateMaps(self) -> None:
self.initialize() self.initialize()
def _onContainerMetadataChanged(self, container): def _onContainerMetadataChanged(self, container: InstanceContainer) -> None:
self._onContainerChanged(container) self._onContainerChanged(container)
def _onContainerChanged(self, container): def _onContainerChanged(self, container: InstanceContainer) -> None:
container_type = container.getMetaDataEntry("type") container_type = container.getMetaDataEntry("type")
if container_type not in ("quality", "quality_changes"): if container_type not in ("quality", "quality_changes"):
return return
@ -160,7 +151,7 @@ class QualityManager(QObject):
self._update_timer.start() self._update_timer.start()
# Updates the given quality groups' availabilities according to which extruders are being used/ enabled. # Updates the given quality groups' availabilities according to which extruders are being used/ enabled.
def _updateQualityGroupsAvailability(self, machine: "GlobalStack", quality_group_list): def _updateQualityGroupsAvailability(self, machine: "GlobalStack", quality_group_list) -> None:
used_extruders = set() used_extruders = set()
for i in range(machine.getProperty("machine_extruder_count", "value")): for i in range(machine.getProperty("machine_extruder_count", "value")):
if str(i) in machine.extruders and machine.extruders[str(i)].isEnabled: if str(i) in machine.extruders and machine.extruders[str(i)].isEnabled:
@ -208,46 +199,60 @@ class QualityManager(QObject):
# Whether a QualityGroup is available can be unknown via the field QualityGroup.is_available. # Whether a QualityGroup is available can be unknown via the field QualityGroup.is_available.
# For more details, see QualityGroup. # For more details, see QualityGroup.
# #
def getQualityGroups(self, machine: "GlobalStack") -> dict: def getQualityGroups(self, machine: "GlobalStack") -> Dict[str, QualityGroup]:
machine_definition_id = getMachineDefinitionIDForQualitySearch(machine.definition) machine_definition_id = getMachineDefinitionIDForQualitySearch(machine.definition)
# This determines if we should only get the global qualities for the global stack and skip the global qualities for the extruder stacks # This determines if we should only get the global qualities for the global stack and skip the global qualities for the extruder stacks
has_variant_materials = parseBool(machine.getMetaDataEntry("has_variant_materials", False)) has_machine_specific_qualities = machine.getHasMachineQuality()
# To find the quality container for the GlobalStack, check in the following fall-back manner: # To find the quality container for the GlobalStack, check in the following fall-back manner:
# (1) the machine-specific node # (1) the machine-specific node
# (2) the generic node # (2) the generic node
machine_node = self._machine_variant_material_quality_type_to_quality_dict.get(machine_definition_id) machine_node = self._machine_nozzle_buildplate_material_quality_type_to_quality_dict.get(machine_definition_id)
default_machine_node = self._machine_variant_material_quality_type_to_quality_dict.get(self._default_machine_definition_id)
nodes_to_check = [machine_node, default_machine_node] # Check if this machine has specific quality profiles for its extruders, if so, when looking up extruder
# qualities, we should not fall back to use the global qualities.
has_extruder_specific_qualities = False
if machine_node:
if machine_node.children_map:
has_extruder_specific_qualities = True
default_machine_node = self._machine_nozzle_buildplate_material_quality_type_to_quality_dict.get(self._default_machine_definition_id)
nodes_to_check = [] # type: List[QualityNode]
if machine_node is not None:
nodes_to_check.append(machine_node)
if default_machine_node is not None:
nodes_to_check.append(default_machine_node)
# Iterate over all quality_types in the machine node # Iterate over all quality_types in the machine node
quality_group_dict = {} quality_group_dict = {}
for node in nodes_to_check: for node in nodes_to_check:
if node and node.quality_type_map: if node and node.quality_type_map:
# Only include global qualities
if has_variant_materials:
quality_node = list(node.quality_type_map.values())[0] quality_node = list(node.quality_type_map.values())[0]
is_global_quality = parseBool(quality_node.metadata.get("global_quality", False)) is_global_quality = parseBool(quality_node.getMetaDataEntry("global_quality", False))
if not is_global_quality: if not is_global_quality:
continue continue
for quality_type, quality_node in node.quality_type_map.items(): for quality_type, quality_node in node.quality_type_map.items():
quality_group = QualityGroup(quality_node.metadata["name"], quality_type) quality_group = QualityGroup(quality_node.getMetaDataEntry("name", ""), quality_type)
quality_group.node_for_global = quality_node quality_group.setGlobalNode(quality_node)
quality_group_dict[quality_type] = quality_group quality_group_dict[quality_type] = quality_group
break break
buildplate_name = machine.getBuildplateName()
# Iterate over all extruders to find quality containers for each extruder # Iterate over all extruders to find quality containers for each extruder
for position, extruder in machine.extruders.items(): for position, extruder in machine.extruders.items():
variant_name = None nozzle_name = None
if extruder.variant.getId() != "empty_variant": if extruder.variant.getId() != "empty_variant":
variant_name = extruder.variant.getName() nozzle_name = extruder.variant.getName()
# This is a list of root material IDs to use for searching for suitable quality profiles. # This is a list of root material IDs to use for searching for suitable quality profiles.
# The root material IDs in this list are in prioritized order. # The root material IDs in this list are in prioritized order.
root_material_id_list = [] root_material_id_list = []
has_material = False # flag indicating whether this extruder has a material assigned has_material = False # flag indicating whether this extruder has a material assigned
root_material_id = None
if extruder.material.getId() != "empty_material": if extruder.material.getId() != "empty_material":
has_material = True has_material = True
root_material_id = extruder.material.getMetaDataEntry("base_file") root_material_id = extruder.material.getMetaDataEntry("base_file")
@ -255,61 +260,90 @@ class QualityManager(QObject):
root_material_id = self._material_manager.getRootMaterialIDWithoutDiameter(root_material_id) root_material_id = self._material_manager.getRootMaterialIDWithoutDiameter(root_material_id)
root_material_id_list.append(root_material_id) root_material_id_list.append(root_material_id)
# Also try to get the fallback material # Also try to get the fallback materials
material_type = extruder.material.getMetaDataEntry("material") fallback_ids = self._material_manager.getFallBackMaterialIdsByMaterial(extruder.material)
fallback_root_material_id = self._material_manager.getFallbackMaterialIdByMaterialType(material_type)
if fallback_root_material_id: if fallback_ids:
root_material_id_list.append(fallback_root_material_id) root_material_id_list.extend(fallback_ids)
# Weed out duplicates while preserving the order.
seen = set() # type: Set[str]
root_material_id_list = [x for x in root_material_id_list if x not in seen and not seen.add(x)] # type: ignore
# Here we construct a list of nodes we want to look for qualities with the highest priority first. # Here we construct a list of nodes we want to look for qualities with the highest priority first.
# The use case is that, when we look for qualities for a machine, we first want to search in the following # The use case is that, when we look for qualities for a machine, we first want to search in the following
# order: # order:
# 1. machine-variant-and-material-specific qualities if exist # 1. machine-nozzle-buildplate-and-material-specific qualities if exist
# 2. machine-variant-specific qualities if exist # 2. machine-nozzle-and-material-specific qualities if exist
# 3. machine-material-specific qualities if exist # 3. machine-nozzle-specific qualities if exist
# 4. machine-specific qualities if exist # 4. machine-material-specific qualities if exist
# 5. generic qualities if exist # 5. machine-specific global qualities if exist, otherwise generic global qualities
# NOTE: We DO NOT fail back to generic global qualities if machine-specific global qualities exist.
# This is because when a machine defines its own global qualities such as Normal, Fine, etc.,
# it is intended to maintain those specific qualities ONLY. If we still fail back to the generic
# global qualities, there can be unimplemented quality types e.g. "coarse", and this is not
# correct.
# Each points above can be represented as a node in the lookup tree, so here we simply put those nodes into # Each points above can be represented as a node in the lookup tree, so here we simply put those nodes into
# the list with priorities as the order. Later, we just need to loop over each node in this list and fetch # the list with priorities as the order. Later, we just need to loop over each node in this list and fetch
# qualities from there. # qualities from there.
node_info_list_0 = [nozzle_name, buildplate_name, root_material_id] # type: List[Optional[str]]
nodes_to_check = [] nodes_to_check = []
if variant_name: # This function tries to recursively find the deepest (the most specific) branch and add those nodes to
# In this case, we have both a specific variant and a specific material # the search list in the order described above. So, by iterating over that search node list, we first look
variant_node = machine_node.getChildNode(variant_name) # in the more specific branches and then the less specific (generic) ones.
if variant_node and has_material: def addNodesToCheck(node: Optional[QualityNode], nodes_to_check_list: List[QualityNode], node_info_list, node_info_idx: int) -> None:
for root_material_id in root_material_id_list: if node is None:
material_node = variant_node.getChildNode(root_material_id) return
if material_node:
nodes_to_check.append(material_node) if node_info_idx < len(node_info_list):
break node_name = node_info_list[node_info_idx]
nodes_to_check.append(variant_node) if node_name is not None:
current_node = node.getChildNode(node_name)
if current_node is not None and has_material:
addNodesToCheck(current_node, nodes_to_check_list, node_info_list, node_info_idx + 1)
# In this case, we only have a specific material but NOT a variant
if has_material: if has_material:
for root_material_id in root_material_id_list: for rmid in root_material_id_list:
material_node = machine_node.getChildNode(root_material_id) material_node = node.getChildNode(rmid)
if material_node: if material_node:
nodes_to_check.append(material_node) nodes_to_check_list.append(material_node)
break break
nodes_to_check += [machine_node, default_machine_node] nodes_to_check_list.append(node)
for node in nodes_to_check:
addNodesToCheck(machine_node, nodes_to_check, node_info_list_0, 0)
# The last fall back will be the global qualities (either from the machine-specific node or the generic
# node), but we only use one. For details see the overview comments above.
if machine_node is not None and machine_node.quality_type_map:
nodes_to_check += [machine_node]
elif default_machine_node is not None:
nodes_to_check += [default_machine_node]
for node_idx, node in enumerate(nodes_to_check):
if node and node.quality_type_map: if node and node.quality_type_map:
if has_variant_materials: if has_extruder_specific_qualities:
# Only include variant qualities; skip non global qualities # Only include variant qualities; skip non global qualities
quality_node = list(node.quality_type_map.values())[0] quality_node = list(node.quality_type_map.values())[0]
is_global_quality = parseBool(quality_node.metadata.get("global_quality", False)) is_global_quality = parseBool(quality_node.getMetaDataEntry("global_quality", False))
if is_global_quality: if is_global_quality:
continue continue
for quality_type, quality_node in node.quality_type_map.items(): for quality_type, quality_node in node.quality_type_map.items():
if quality_type not in quality_group_dict: if quality_type not in quality_group_dict:
quality_group = QualityGroup(quality_node.metadata["name"], quality_type) quality_group = QualityGroup(quality_node.getMetaDataEntry("name", ""), quality_type)
quality_group_dict[quality_type] = quality_group quality_group_dict[quality_type] = quality_group
quality_group = quality_group_dict[quality_type] quality_group = quality_group_dict[quality_type]
quality_group.nodes_for_extruders[position] = quality_node if position not in quality_group.nodes_for_extruders:
quality_group.setExtruderNode(position, quality_node)
# If the machine has its own specific qualities, for extruders, it should skip the global qualities
# and use the material/variant specific qualities.
if has_extruder_specific_qualities:
if node_idx == len(nodes_to_check) - 1:
break break
# Update availabilities for each quality group # Update availabilities for each quality group
@ -317,14 +351,14 @@ class QualityManager(QObject):
return quality_group_dict return quality_group_dict
def getQualityGroupsForMachineDefinition(self, machine: "GlobalStack") -> dict: def getQualityGroupsForMachineDefinition(self, machine: "GlobalStack") -> Dict[str, QualityGroup]:
machine_definition_id = getMachineDefinitionIDForQualitySearch(machine.definition) machine_definition_id = getMachineDefinitionIDForQualitySearch(machine.definition)
# To find the quality container for the GlobalStack, check in the following fall-back manner: # To find the quality container for the GlobalStack, check in the following fall-back manner:
# (1) the machine-specific node # (1) the machine-specific node
# (2) the generic node # (2) the generic node
machine_node = self._machine_variant_material_quality_type_to_quality_dict.get(machine_definition_id) machine_node = self._machine_nozzle_buildplate_material_quality_type_to_quality_dict.get(machine_definition_id)
default_machine_node = self._machine_variant_material_quality_type_to_quality_dict.get( default_machine_node = self._machine_nozzle_buildplate_material_quality_type_to_quality_dict.get(
self._default_machine_definition_id) self._default_machine_definition_id)
nodes_to_check = [machine_node, default_machine_node] nodes_to_check = [machine_node, default_machine_node]
@ -333,13 +367,20 @@ class QualityManager(QObject):
for node in nodes_to_check: for node in nodes_to_check:
if node and node.quality_type_map: if node and node.quality_type_map:
for quality_type, quality_node in node.quality_type_map.items(): for quality_type, quality_node in node.quality_type_map.items():
quality_group = QualityGroup(quality_node.metadata["name"], quality_type) quality_group = QualityGroup(quality_node.getMetaDataEntry("name", ""), quality_type)
quality_group.node_for_global = quality_node quality_group.setGlobalNode(quality_node)
quality_group_dict[quality_type] = quality_group quality_group_dict[quality_type] = quality_group
break break
return quality_group_dict return quality_group_dict
def getDefaultQualityType(self, machine: "GlobalStack") -> Optional[QualityGroup]:
preferred_quality_type = machine.definition.getMetaDataEntry("preferred_quality_type")
quality_group_dict = self.getQualityGroups(machine)
quality_group = quality_group_dict.get(preferred_quality_type)
return quality_group
# #
# Methods for GUI # Methods for GUI
# #
@ -348,10 +389,21 @@ class QualityManager(QObject):
# Remove the given quality changes group. # Remove the given quality changes group.
# #
@pyqtSlot(QObject) @pyqtSlot(QObject)
def removeQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup"): def removeQualityChangesGroup(self, quality_changes_group: "QualityChangesGroup") -> None:
Logger.log("i", "Removing quality changes group [%s]", quality_changes_group.name) Logger.log("i", "Removing quality changes group [%s]", quality_changes_group.name)
removed_quality_changes_ids = set()
for node in quality_changes_group.getAllNodes(): for node in quality_changes_group.getAllNodes():
self._container_registry.removeContainer(node.metadata["id"]) container_id = node.getMetaDataEntry("id")
self._container_registry.removeContainer(container_id)
removed_quality_changes_ids.add(container_id)
# Reset all machines that have activated this quality changes to empty.
for global_stack in self._container_registry.findContainerStacks(type = "machine"):
if global_stack.qualityChanges.getId() in removed_quality_changes_ids:
global_stack.qualityChanges = self._empty_quality_changes_container
for extruder_stack in self._container_registry.findContainerStacks(type = "extruder_train"):
if extruder_stack.qualityChanges.getId() in removed_quality_changes_ids:
extruder_stack.qualityChanges = self._empty_quality_changes_container
# #
# Rename a set of quality changes containers. Returns the new name. # Rename a set of quality changes containers. Returns the new name.
@ -365,7 +417,9 @@ class QualityManager(QObject):
new_name = self._container_registry.uniqueName(new_name) new_name = self._container_registry.uniqueName(new_name)
for node in quality_changes_group.getAllNodes(): for node in quality_changes_group.getAllNodes():
node.getContainer().setName(new_name) container = node.getContainer()
if container:
container.setName(new_name)
quality_changes_group.name = new_name quality_changes_group.name = new_name
@ -378,7 +432,7 @@ class QualityManager(QObject):
# Duplicates the given quality. # Duplicates the given quality.
# #
@pyqtSlot(str, "QVariantMap") @pyqtSlot(str, "QVariantMap")
def duplicateQualityChanges(self, quality_changes_name, quality_model_item): def duplicateQualityChanges(self, quality_changes_name: str, quality_model_item) -> None:
global_stack = self._application.getGlobalContainerStack() global_stack = self._application.getGlobalContainerStack()
if not global_stack: if not global_stack:
Logger.log("i", "No active global stack, cannot duplicate quality changes.") Logger.log("i", "No active global stack, cannot duplicate quality changes.")
@ -388,7 +442,8 @@ class QualityManager(QObject):
quality_changes_group = quality_model_item["quality_changes_group"] quality_changes_group = quality_model_item["quality_changes_group"]
if quality_changes_group is None: if quality_changes_group is None:
# create global quality changes only # create global quality changes only
new_quality_changes = self._createQualityChanges(quality_group.quality_type, quality_changes_name, new_name = self._container_registry.uniqueName(quality_changes_name)
new_quality_changes = self._createQualityChanges(quality_group.quality_type, new_name,
global_stack, None) global_stack, None)
self._container_registry.addContainer(new_quality_changes) self._container_registry.addContainer(new_quality_changes)
else: else:
@ -406,8 +461,8 @@ class QualityManager(QObject):
# the user containers in each stack. These then replace the quality_changes containers in the # the user containers in each stack. These then replace the quality_changes containers in the
# stack and clear the user settings. # stack and clear the user settings.
@pyqtSlot(str) @pyqtSlot(str)
def createQualityChanges(self, base_name): def createQualityChanges(self, base_name: str) -> None:
machine_manager = Application.getInstance().getMachineManager() machine_manager = self._application.getMachineManager()
global_stack = machine_manager.activeMachine global_stack = machine_manager.activeMachine
if not global_stack: if not global_stack:
@ -457,18 +512,18 @@ class QualityManager(QObject):
# Create a new quality_changes container for the quality. # Create a new quality_changes container for the quality.
quality_changes = InstanceContainer(new_id) quality_changes = InstanceContainer(new_id)
quality_changes.setName(new_name) quality_changes.setName(new_name)
quality_changes.addMetaDataEntry("type", "quality_changes") quality_changes.setMetaDataEntry("type", "quality_changes")
quality_changes.addMetaDataEntry("quality_type", quality_type) quality_changes.setMetaDataEntry("quality_type", quality_type)
# If we are creating a container for an extruder, ensure we add that to the container # If we are creating a container for an extruder, ensure we add that to the container
if extruder_stack is not None: if extruder_stack is not None:
quality_changes.addMetaDataEntry("position", extruder_stack.getMetaDataEntry("position")) quality_changes.setMetaDataEntry("position", extruder_stack.getMetaDataEntry("position"))
# If the machine specifies qualities should be filtered, ensure we match the current criteria. # If the machine specifies qualities should be filtered, ensure we match the current criteria.
machine_definition_id = getMachineDefinitionIDForQualitySearch(machine.definition) machine_definition_id = getMachineDefinitionIDForQualitySearch(machine.definition)
quality_changes.setDefinition(machine_definition_id) quality_changes.setDefinition(machine_definition_id)
quality_changes.addMetaDataEntry("setting_version", self._application.SettingVersion) quality_changes.setMetaDataEntry("setting_version", self._application.SettingVersion)
return quality_changes return quality_changes
@ -485,7 +540,7 @@ class QualityManager(QObject):
# Example: for an Ultimaker 3 Extended, it has "quality_definition = ultimaker3". This means Ultimaker 3 Extended # Example: for an Ultimaker 3 Extended, it has "quality_definition = ultimaker3". This means Ultimaker 3 Extended
# shares the same set of qualities profiles as Ultimaker 3. # shares the same set of qualities profiles as Ultimaker 3.
# #
def getMachineDefinitionIDForQualitySearch(machine_definition: "DefinitionContainer", def getMachineDefinitionIDForQualitySearch(machine_definition: "DefinitionContainerInterface",
default_definition_id: str = "fdmprinter") -> str: default_definition_id: str = "fdmprinter") -> str:
machine_definition_id = default_definition_id machine_definition_id = default_definition_id
if parseBool(machine_definition.getMetaDataEntry("has_machine_quality", False)): if parseBool(machine_definition.getMetaDataEntry("has_machine_quality", False)):

View file

@ -1,7 +1,7 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional from typing import Optional, Dict, cast, Any
from .ContainerNode import ContainerNode from .ContainerNode import ContainerNode
from .QualityChangesGroup import QualityChangesGroup from .QualityChangesGroup import QualityChangesGroup
@ -12,18 +12,21 @@ from .QualityChangesGroup import QualityChangesGroup
# #
class QualityNode(ContainerNode): class QualityNode(ContainerNode):
def __init__(self, metadata: Optional[dict] = None): def __init__(self, metadata: Optional[Dict[str, Any]] = None) -> None:
super().__init__(metadata = metadata) super().__init__(metadata = metadata)
self.quality_type_map = {} # quality_type -> QualityNode for InstanceContainer self.quality_type_map = {} # type: Dict[str, QualityNode] # quality_type -> QualityNode for InstanceContainer
def addQualityMetadata(self, quality_type: str, metadata: dict): def getChildNode(self, child_key: str) -> Optional["QualityNode"]:
return self.children_map.get(child_key)
def addQualityMetadata(self, quality_type: str, metadata: Dict[str, Any]):
if quality_type not in self.quality_type_map: if quality_type not in self.quality_type_map:
self.quality_type_map[quality_type] = QualityNode(metadata) self.quality_type_map[quality_type] = QualityNode(metadata)
def getQualityNode(self, quality_type: str) -> Optional["QualityNode"]: def getQualityNode(self, quality_type: str) -> Optional["QualityNode"]:
return self.quality_type_map.get(quality_type) return self.quality_type_map.get(quality_type)
def addQualityChangesMetadata(self, quality_type: str, metadata: dict): def addQualityChangesMetadata(self, quality_type: str, metadata: Dict[str, Any]):
if quality_type not in self.quality_type_map: if quality_type not in self.quality_type_map:
self.quality_type_map[quality_type] = QualityNode() self.quality_type_map[quality_type] = QualityNode()
quality_type_node = self.quality_type_map[quality_type] quality_type_node = self.quality_type_map[quality_type]
@ -32,4 +35,4 @@ class QualityNode(ContainerNode):
if name not in quality_type_node.children_map: if name not in quality_type_node.children_map:
quality_type_node.children_map[name] = QualityChangesGroup(name, quality_type) quality_type_node.children_map[name] = QualityChangesGroup(name, quality_type)
quality_changes_group = quality_type_node.children_map[name] quality_changes_group = quality_type_node.children_map[name]
quality_changes_group.addNode(QualityNode(metadata)) cast(QualityChangesGroup, quality_changes_group).addNode(QualityNode(metadata))

View file

@ -1,9 +1,8 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from enum import Enum
from collections import OrderedDict from collections import OrderedDict
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING, Dict
from UM.ConfigurationErrorMessage import ConfigurationErrorMessage from UM.ConfigurationErrorMessage import ConfigurationErrorMessage
from UM.Logger import Logger from UM.Logger import Logger
@ -11,20 +10,13 @@ from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.Util import parseBool from UM.Util import parseBool
from cura.Machines.ContainerNode import ContainerNode from cura.Machines.ContainerNode import ContainerNode
from cura.Machines.VariantType import VariantType, ALL_VARIANT_TYPES
from cura.Settings.GlobalStack import GlobalStack from cura.Settings.GlobalStack import GlobalStack
if TYPE_CHECKING: if TYPE_CHECKING:
from UM.Settings.DefinitionContainer import DefinitionContainer from UM.Settings.DefinitionContainer import DefinitionContainer
class VariantType(Enum):
BUILD_PLATE = "buildplate"
NOZZLE = "nozzle"
ALL_VARIANT_TYPES = (VariantType.BUILD_PLATE, VariantType.NOZZLE)
# #
# VariantManager is THE place to look for a specific variant. It maintains two variant lookup tables with the following # VariantManager is THE place to look for a specific variant. It maintains two variant lookup tables with the following
# structure: # structure:
@ -44,11 +36,11 @@ ALL_VARIANT_TYPES = (VariantType.BUILD_PLATE, VariantType.NOZZLE)
# #
class VariantManager: class VariantManager:
def __init__(self, container_registry): def __init__(self, container_registry: ContainerRegistry) -> None:
self._container_registry = container_registry # type: ContainerRegistry self._container_registry = container_registry
self._machine_to_variant_dict_map = dict() # <machine_type> -> <variant_dict> self._machine_to_variant_dict_map = dict() # type: Dict[str, Dict["VariantType", Dict[str, ContainerNode]]]
self._machine_to_buildplate_dict_map = dict() self._machine_to_buildplate_dict_map = dict() # type: Dict[str, Dict[str, ContainerNode]]
self._exclude_variant_id_list = ["empty_variant"] self._exclude_variant_id_list = ["empty_variant"]
@ -56,7 +48,7 @@ class VariantManager:
# Initializes the VariantManager including: # Initializes the VariantManager including:
# - initializing the variant lookup table based on the metadata in ContainerRegistry. # - initializing the variant lookup table based on the metadata in ContainerRegistry.
# #
def initialize(self): def initialize(self) -> None:
self._machine_to_variant_dict_map = OrderedDict() self._machine_to_variant_dict_map = OrderedDict()
self._machine_to_buildplate_dict_map = OrderedDict() self._machine_to_buildplate_dict_map = OrderedDict()
@ -114,26 +106,33 @@ class VariantManager:
variant_node = variant_dict[variant_name] variant_node = variant_dict[variant_name]
break break
return variant_node return variant_node
return self._machine_to_variant_dict_map[machine_definition_id].get(variant_type, {}).get(variant_name)
def getVariantNodes(self, machine: "GlobalStack", return self._machine_to_variant_dict_map.get(machine_definition_id, {}).get(variant_type, {}).get(variant_name)
variant_type: Optional["VariantType"] = None) -> dict:
def getVariantNodes(self, machine: "GlobalStack", variant_type: "VariantType") -> Dict[str, ContainerNode]:
machine_definition_id = machine.definition.getId() machine_definition_id = machine.definition.getId()
return self._machine_to_variant_dict_map.get(machine_definition_id, {}).get(variant_type, {}) return self._machine_to_variant_dict_map.get(machine_definition_id, {}).get(variant_type, {})
# #
# Gets the default variant for the given machine definition. # Gets the default variant for the given machine definition.
# If the optional GlobalStack is given, the metadata information will be fetched from the GlobalStack instead of
# the DefinitionContainer. Because for machines such as UM2, you can enable Olsson Block, which will set
# "has_variants" to True in the GlobalStack. In those cases, we need to fetch metadata from the GlobalStack or
# it may not be correct.
# #
def getDefaultVariantNode(self, machine_definition: "DefinitionContainer", def getDefaultVariantNode(self, machine_definition: "DefinitionContainer",
variant_type: VariantType) -> Optional["ContainerNode"]: variant_type: "VariantType",
global_stack: Optional["GlobalStack"] = None) -> Optional["ContainerNode"]:
machine_definition_id = machine_definition.getId() machine_definition_id = machine_definition.getId()
container_for_metadata_fetching = global_stack if global_stack is not None else machine_definition
preferred_variant_name = None preferred_variant_name = None
if variant_type == VariantType.BUILD_PLATE: if variant_type == VariantType.BUILD_PLATE:
if parseBool(machine_definition.getMetaDataEntry("has_variant_buildplates", False)): if parseBool(container_for_metadata_fetching.getMetaDataEntry("has_variant_buildplates", False)):
preferred_variant_name = machine_definition.getMetaDataEntry("preferred_variant_buildplate_name") preferred_variant_name = container_for_metadata_fetching.getMetaDataEntry("preferred_variant_buildplate_name")
else: else:
if parseBool(machine_definition.getMetaDataEntry("has_variants", False)): if parseBool(container_for_metadata_fetching.getMetaDataEntry("has_variants", False)):
preferred_variant_name = machine_definition.getMetaDataEntry("preferred_variant_name") preferred_variant_name = container_for_metadata_fetching.getMetaDataEntry("preferred_variant_name")
node = None node = None
if preferred_variant_name: if preferred_variant_name:

View file

@ -0,0 +1,15 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from enum import Enum
class VariantType(Enum):
BUILD_PLATE = "buildplate"
NOZZLE = "nozzle"
ALL_VARIANT_TYPES = (VariantType.BUILD_PLATE, VariantType.NOZZLE)
__all__ = ["VariantType", "ALL_VARIANT_TYPES"]

View file

@ -1,6 +1,8 @@
# Copyright (c) 2017 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import copy
from UM.Job import Job from UM.Job import Job
from UM.Operations.GroupedOperation import GroupedOperation from UM.Operations.GroupedOperation import GroupedOperation
from UM.Message import Message from UM.Message import Message
@ -23,18 +25,25 @@ class MultiplyObjectsJob(Job):
def run(self): def run(self):
status_message = Message(i18n_catalog.i18nc("@info:status", "Multiplying and placing objects"), lifetime=0, status_message = Message(i18n_catalog.i18nc("@info:status", "Multiplying and placing objects"), lifetime=0,
dismissable=False, progress=0, title = i18n_catalog.i18nc("@info:title", "Placing Object")) dismissable=False, progress=0, title = i18n_catalog.i18nc("@info:title", "Placing Objects"))
status_message.show() status_message.show()
scene = Application.getInstance().getController().getScene() scene = Application.getInstance().getController().getScene()
total_progress = len(self._objects) * self._count total_progress = len(self._objects) * self._count
current_progress = 0 current_progress = 0
global_container_stack = Application.getInstance().getGlobalContainerStack()
machine_width = global_container_stack.getProperty("machine_width", "value")
machine_depth = global_container_stack.getProperty("machine_depth", "value")
root = scene.getRoot() root = scene.getRoot()
arranger = Arrange.create(scene_root=root) scale = 0.5
arranger = Arrange.create(x = machine_width, y = machine_depth, scene_root = root, scale = scale, min_offset = self._min_offset)
processed_nodes = [] processed_nodes = []
nodes = [] nodes = []
not_fit_count = 0
for node in self._objects: for node in self._objects:
# If object is part of a group, multiply group # If object is part of a group, multiply group
current_node = node current_node = node
@ -46,21 +55,26 @@ class MultiplyObjectsJob(Job):
processed_nodes.append(current_node) processed_nodes.append(current_node)
node_too_big = False node_too_big = False
if node.getBoundingBox().width < 300 or node.getBoundingBox().depth < 300: 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) offset_shape_arr, hull_shape_arr = ShapeArray.fromNode(current_node, min_offset = self._min_offset, scale = scale)
else: else:
node_too_big = True node_too_big = True
found_solution_for_all = True found_solution_for_all = True
arranger.resetLastPriority()
for i in range(self._count): for i in range(self._count):
# We do place the nodes one by one, as we want to yield in between. # 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 not node_too_big:
new_node, solution_found = arranger.findNodePlacement(current_node, offset_shape_arr, hull_shape_arr) solution_found = arranger.findNodePlacement(new_node, offset_shape_arr, hull_shape_arr)
if node_too_big or not solution_found: if node_too_big or not solution_found:
found_solution_for_all = False found_solution_for_all = False
new_location = new_node.getPosition() new_location = new_node.getPosition()
new_location = new_location.set(z = 100 - i * 20) new_location = new_location.set(z = - not_fit_count * 20)
new_node.setPosition(new_location) new_node.setPosition(new_location)
not_fit_count += 1
# Same build plate # Same build plate
build_plate_number = current_node.callDecoration("getBuildPlateNumber") build_plate_number = current_node.callDecoration("getBuildPlateNumber")

View file

@ -0,0 +1,128 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime
import json
import random
from hashlib import sha512
from base64 import b64encode
from typing import Optional
import requests
from UM.i18n import i18nCatalog
from UM.Logger import Logger
from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settings
catalog = i18nCatalog("cura")
TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
## Class containing several helpers to deal with the authorization flow.
class AuthorizationHelpers:
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":
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":
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 "",
"grant_type": "authorization_code",
"code": authorization_code,
"code_verifier": verification_code,
"scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "",
}
try:
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore
except requests.exceptions.ConnectionError:
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server")
## 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":
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 "",
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "",
}
try:
return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore
except requests.exceptions.ConnectionError:
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server")
@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":
token_data = None
try:
token_data = json.loads(token_response.text)
except ValueError:
Logger.log("w", "Could not parse token response data: %s", token_response.text)
if not token_data:
return AuthenticationResponse(success = False, err_message = catalog.i18nc("@message", "Could not read response."))
if token_response.status_code not in (200, 201):
return AuthenticationResponse(success = False, err_message = token_data["error_description"])
return AuthenticationResponse(success=True,
token_type=token_data["token_type"],
access_token=token_data["access_token"],
refresh_token=token_data["refresh_token"],
expires_in=token_data["expires_in"],
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"]:
try:
token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = {
"Authorization": "Bearer {}".format(access_token)
})
except requests.exceptions.ConnectionError:
# Connection was suddenly dropped. Nothing we can do about that.
Logger.log("w", "Something failed while attempting to parse the JWT token")
return None
if token_request.status_code not in (200, 201):
Logger.log("w", "Could not retrieve token data from auth server: %s", token_request.text)
return None
user_data = token_request.json().get("data")
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", "")
)
@staticmethod
## Generate a 16-character verification code.
# \param code_length: How long should the code be?
def generateVerificationCode(code_length: int = 16) -> str:
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:
encoded = sha512(verification_code.encode()).digest()
return b64encode(encoded, altchars = b"_-").decode()

View file

@ -0,0 +1,103 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from http.server import BaseHTTPRequestHandler
from typing import Optional, Callable, Tuple, Dict, Any, List, TYPE_CHECKING
from urllib.parse import parse_qs, urlparse
from cura.OAuth2.Models import AuthenticationResponse, ResponseData, HTTP_STATUS
from UM.i18n import i18nCatalog
if TYPE_CHECKING:
from cura.OAuth2.Models import ResponseStatus
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers
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):
def __init__(self, request, client_address, server) -> None:
super().__init__(request, client_address, server)
# These values will be injected by the HTTPServer that this handler belongs to.
self.authorization_helpers = None # type: Optional[AuthorizationHelpers]
self.authorization_callback = None # type: Optional[Callable[[AuthenticationResponse], None]]
self.verification_code = None # type: Optional[str]
def do_GET(self) -> None:
# Extract values from the query string.
parsed_url = urlparse(self.path)
query = parse_qs(parsed_url.query)
# Handle the possible requests
if parsed_url.path == "/callback":
server_response, token_response = self._handleCallback(query)
else:
server_response = self._handleNotFound()
token_response = None
# Send the data to the browser.
self._sendHeaders(server_response.status, server_response.content_type, server_response.redirect_uri)
if server_response.data_stream:
# If there is data in the response, we send it.
self._sendData(server_response.data_stream)
if token_response and self.authorization_callback is not None:
# Trigger the callback if we got a response.
# 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]]:
code = self._queryGet(query, "code")
if code and self.authorization_helpers is not None and self.verification_code is not None:
# If the code was returned we get the access token.
token_response = self.authorization_helpers.getAccessTokenUsingAuthorizationCode(
code, self.verification_code)
elif self._queryGet(query, "error_code") == "user_denied":
# Otherwise we show an error message (probably the user clicked "Deny" in the auth dialog).
token_response = AuthenticationResponse(
success = False,
err_message = catalog.i18nc("@message", "Please give the required permissions when authorizing this application.")
)
else:
# We don't know what went wrong here, so instruct the user to check the logs.
token_response = AuthenticationResponse(
success = False,
error_message = catalog.i18nc("@message", "Something unexpected happened when trying to log in, please try again.")
)
if self.authorization_helpers is None:
return ResponseData(), token_response
return ResponseData(
status = HTTP_STATUS["REDIRECT"],
data_stream = b"Redirecting...",
redirect_uri = self.authorization_helpers.settings.AUTH_SUCCESS_REDIRECT if token_response.success else
self.authorization_helpers.settings.AUTH_FAILED_REDIRECT
), token_response
## Handle all other non-existing server calls.
@staticmethod
def _handleNotFound() -> ResponseData:
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:
self.send_response(status.code, status.message)
self.send_header("Content-type", content_type)
if redirect_uri:
self.send_header("Location", redirect_uri)
self.end_headers()
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]:
return query_data.get(key, [default])[0]

View file

@ -0,0 +1,27 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from http.server import HTTPServer
from typing import Callable, Any, TYPE_CHECKING
if TYPE_CHECKING:
from cura.OAuth2.Models import AuthenticationResponse
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.
def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None:
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:
self.RequestHandlerClass.authorization_callback = authorization_callback # type: ignore
## Set the verification code on the request handler.
def setVerificationCode(self, verification_code: str) -> None:
self.RequestHandlerClass.verification_code = verification_code # type: ignore

View file

@ -0,0 +1,211 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json
import webbrowser
from datetime import datetime, timedelta
from typing import Optional, TYPE_CHECKING
from urllib.parse import urlencode
import requests.exceptions
from UM.Logger import Logger
from UM.Message import Message
from UM.Signal import Signal
from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT
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
## The authorization service is responsible for handling the login flow,
# storing user credentials and providing account information.
class AuthorizationService:
# Emit signal when authentication is completed.
onAuthStateChanged = Signal()
# Emit signal when authentication failed.
onAuthenticationError = Signal()
def __init__(self, settings: "OAuth2Settings", preferences: Optional["Preferences"] = None) -> None:
self._settings = settings
self._auth_helpers = AuthorizationHelpers(settings)
self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL)
self._auth_data = None # type: Optional[AuthenticationResponse]
self._user_profile = None # type: Optional["UserProfile"]
self._preferences = preferences
self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True)
self._unable_to_get_data_message = None # type: Optional[Message]
self.onAuthStateChanged.connect(self._authChanged)
def _authChanged(self, logged_in):
if logged_in and self._unable_to_get_data_message is not None:
self._unable_to_get_data_message.hide()
def initialize(self, preferences: Optional["Preferences"] = None) -> None:
if preferences is not None:
self._preferences = preferences
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"]:
if not self._user_profile:
# If no user profile was stored locally, we try to get it from JWT.
try:
self._user_profile = self._parseJWT()
except requests.exceptions.ConnectionError:
# Unable to get connection, can't login.
return None
if not self._user_profile and self._auth_data:
# If there is still no user profile from the JWT, we have to log in again.
Logger.log("w", "The user profile could not be loaded. The user must log in again!")
self.deleteAuthData()
return None
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"]:
if not self._auth_data or self._auth_data.access_token is None:
# If no auth data exists, we should always log in again.
return None
user_data = self._auth_helpers.parseJWT(self._auth_data.access_token)
if user_data:
# If the profile was found, we return it immediately.
return user_data
# The JWT was expired or invalid and we should request a new one.
if self._auth_data.refresh_token is None:
return None
self._auth_data = self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)
if not self._auth_data or self._auth_data.access_token is None:
# The token could not be refreshed using the refresh token. We should login again.
return None
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]:
if self._auth_data is None:
Logger.log("d", "No auth data to retrieve the access_token from")
return None
# Check if the current access token is expired and refresh it if that is the case.
# We have a fallback on a date far in the past for currently stored auth data in cura.cfg.
received_at = datetime.strptime(self._auth_data.received_at, TOKEN_TIMESTAMP_FORMAT) \
if self._auth_data.received_at else datetime(2000, 1, 1)
expiry_date = received_at + timedelta(seconds = float(self._auth_data.expires_in or 0) - 60)
if datetime.now() > expiry_date:
self.refreshAccessToken()
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:
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
response = self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)
if response.success:
self._storeAuthData(response)
self.onAuthStateChanged.emit(logged_in = True)
else:
self.onAuthStateChanged.emit(logged_in = False)
## Delete the authentication data that we have stored locally (eg; logout)
def deleteAuthData(self) -> None:
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:
Logger.log("d", "Starting new OAuth2 flow...")
# Create the tokens needed for the code challenge (PKCE) extension for OAuth2.
# This is needed because the CuraDrivePlugin is a untrusted (open source) client.
# More details can be found at https://tools.ietf.org/html/rfc7636.
verification_code = self._auth_helpers.generateVerificationCode()
challenge_code = self._auth_helpers.generateVerificationCodeChallenge(verification_code)
# Create the query string needed for the OAuth2 flow.
query_string = urlencode({
"client_id": self._settings.CLIENT_ID,
"redirect_uri": self._settings.CALLBACK_URL,
"scope": self._settings.CLIENT_SCOPES,
"response_type": "code",
"state": "(.Y.)",
"code_challenge": challenge_code,
"code_challenge_method": "S512"
})
# Open the authorization page in a new browser window.
webbrowser.open_new("{}?{}".format(self._auth_url, query_string))
# Start a local web server to receive the callback URL on.
self._server.start(verification_code)
## Callback method for the authentication flow.
def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:
if auth_response.success:
self._storeAuthData(auth_response)
self.onAuthStateChanged.emit(logged_in = True)
else:
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:
if self._preferences is None:
Logger.log("e", "Unable to load authentication data, since no preference has been set!")
return
try:
preferences_data = json.loads(self._preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY))
if preferences_data:
self._auth_data = AuthenticationResponse(**preferences_data)
# Also check if we can actually get the user profile information.
user_profile = self.getUserProfile()
if user_profile is not None:
self.onAuthStateChanged.emit(logged_in = True)
else:
if self._unable_to_get_data_message is not None:
self._unable_to_get_data_message.hide()
self._unable_to_get_data_message = Message(i18n_catalog.i18nc("@info", "Unable to reach the Ultimaker account server."), title = i18n_catalog.i18nc("@info:title", "Warning"))
self._unable_to_get_data_message.addAction("retry", i18n_catalog.i18nc("@action:button", "Retry"), "[no_icon]", "[no_description]")
self._unable_to_get_data_message.actionTriggered.connect(self._onMessageActionTriggered)
self._unable_to_get_data_message.show()
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:
if self._preferences is None:
Logger.log("e", "Unable to save authentication data, since no preference has been set!")
return
self._auth_data = auth_data
if auth_data:
self._user_profile = self.getUserProfile()
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data)))
else:
self._user_profile = None
self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)
def _onMessageActionTriggered(self, _, action):
if action == "retry":
self.loadAuthDataFromPreferences()

View file

@ -0,0 +1,68 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import threading
from typing import Optional, Callable, Any, TYPE_CHECKING
from UM.Logger import Logger
from cura.OAuth2.AuthorizationRequestServer import AuthorizationRequestServer
from cura.OAuth2.AuthorizationRequestHandler import AuthorizationRequestHandler
if TYPE_CHECKING:
from cura.OAuth2.Models import AuthenticationResponse
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers
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:
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
self._auth_helpers = auth_helpers
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.
def start(self, verification_code: str) -> None:
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.
self._web_server.setVerificationCode(verification_code)
return
if self._web_server_port is None:
raise Exception("Unable to start server without specifying the port.")
Logger.log("d", "Starting local web server to handle authorization callback on port %s", self._web_server_port)
# Create the server and inject the callback and code.
self._web_server = AuthorizationRequestServer(("0.0.0.0", self._web_server_port), AuthorizationRequestHandler)
self._web_server.setAuthorizationHelpers(self._auth_helpers)
self._web_server.setAuthorizationCallback(self._auth_state_changed_callback)
self._web_server.setVerificationCode(verification_code)
# 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.start()
## Stops the web server if it was running. It also does some cleanup.
def stop(self) -> None:
Logger.log("d", "Stopping local oauth2 web server...")
if self._web_server:
self._web_server.server_close()
self._web_server = None
self._web_server_thread = None

61
cura/OAuth2/Models.py Normal file
View file

@ -0,0 +1,61 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
class BaseModel:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
## OAuth OAuth2Settings data template.
class OAuth2Settings(BaseModel):
CALLBACK_PORT = None # type: Optional[int]
OAUTH_SERVER_URL = None # type: Optional[str]
CLIENT_ID = None # type: Optional[str]
CLIENT_SCOPES = None # type: Optional[str]
CALLBACK_URL = None # type: Optional[str]
AUTH_DATA_PREFERENCE_KEY = "" # type: str
AUTH_SUCCESS_REDIRECT = "https://ultimaker.com" # type: str
AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str
## User profile data template.
class UserProfile(BaseModel):
user_id = None # type: Optional[str]
username = None # type: Optional[str]
profile_image_url = None # type: Optional[str]
## Authentication data template.
class AuthenticationResponse(BaseModel):
"""Data comes from the token response with success flag and error message added."""
success = True # type: bool
token_type = None # type: Optional[str]
access_token = None # type: Optional[str]
refresh_token = None # type: Optional[str]
expires_in = None # type: Optional[str]
scope = None # type: Optional[str]
err_message = None # type: Optional[str]
received_at = None # type: Optional[str]
## Response status template.
class ResponseStatus(BaseModel):
code = 200 # type: int
message = "" # type: str
## Response data template.
class ResponseData(BaseModel):
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 = {
"OK": ResponseStatus(code = 200, message = "OK"),
"NOT_FOUND": ResponseStatus(code = 404, message = "NOT FOUND"),
"REDIRECT": ResponseStatus(code = 302, message = "REDIRECT")
}

2
cura/OAuth2/__init__.py Normal file
View file

@ -0,0 +1,2 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.

View file

@ -1,112 +1,149 @@
# Copyright (c) 2015 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from UM.Scene.Iterator import Iterator import sys
from UM.Scene.SceneNode import SceneNode
from functools import cmp_to_key from shapely import affinity
from UM.Application import Application from shapely.geometry import Polygon
from UM.Scene.Iterator.Iterator import Iterator
from UM.Scene.SceneNode import SceneNode
# Iterator that determines the object print order when one-at a time mode is enabled.
#
# In one-at-a-time mode, only one extruder can be enabled to print. In order to maximize the number of objects we can
# print, we need to print from the corner that's closest to the extruder that's being used. Here is an illustration:
#
# +--------------------------------+
# | |
# | |
# | | - Rectangle represents the complete print head including fans, etc.
# | X X | y - X's are the nozzles
# | (1) (2) | ^
# | | |
# +--------------------------------+ +--> x
#
# In this case, the nozzles are symmetric, nozzle (1) is closer to the bottom left corner while (2) is closer to the
# bottom right. If we use nozzle (1) to print, then we better off printing from the bottom left corner so the print
# head will not collide into an object on its top-right side, which is a very large unused area. Following the same
# logic, if we are printing with nozzle (2), then it's better to print from the bottom-right side.
#
# This iterator determines the print order following the rules above.
#
class OneAtATimeIterator(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)
class OneAtATimeIterator(Iterator.Iterator):
def __init__(self, scene_node): def __init__(self, scene_node):
super().__init__(scene_node) # Call super to make multiple inheritence work. from cura.CuraApplication import CuraApplication
self._hit_map = [[]] self._global_stack = CuraApplication.getInstance().getGlobalContainerStack()
self._original_node_list = [] self._original_node_list = []
super().__init__(scene_node) # Call super to make multiple inheritance work.
def getMachineNearestCornerToExtruder(self, global_stack):
head_and_fans_coordinates = global_stack.getHeadAndFansCoordinates()
used_extruder = None
for extruder in global_stack.extruders.values():
if extruder.isEnabled:
used_extruder = extruder
break
extruder_offsets = [used_extruder.getProperty("machine_nozzle_offset_x", "value"),
used_extruder.getProperty("machine_nozzle_offset_y", "value")]
# find the corner that's closest to the origin
min_distance2 = sys.maxsize
min_coord = None
for coord in head_and_fans_coordinates:
x = coord[0] - extruder_offsets[0]
y = coord[1] - extruder_offsets[1]
distance2 = x**2 + y**2
if distance2 <= min_distance2:
min_distance2 = distance2
min_coord = coord
return min_coord
def _checkForCollisions(self) -> bool:
all_nodes = []
for node in self._scene_node.getChildren():
if not issubclass(type(node), SceneNode):
continue
convex_hull = node.callDecoration("getConvexHullHead")
if not convex_hull:
continue
bounding_box = node.getBoundingBox()
if not bounding_box:
continue
from UM.Math.Polygon import Polygon
bounding_box_polygon = Polygon([[bounding_box.left, bounding_box.front],
[bounding_box.left, bounding_box.back],
[bounding_box.right, bounding_box.back],
[bounding_box.right, bounding_box.front]])
all_nodes.append({"node": node,
"bounding_box": bounding_box_polygon,
"convex_hull": convex_hull})
has_collisions = False
for i, node_dict in enumerate(all_nodes):
for j, other_node_dict in enumerate(all_nodes):
if i == j:
continue
if node_dict["bounding_box"].intersectsPolygon(other_node_dict["convex_hull"]):
has_collisions = True
break
if has_collisions:
break
return has_collisions
def _fillStack(self): def _fillStack(self):
min_coord = self.getMachineNearestCornerToExtruder(self._global_stack)
transform_x = -int(round(min_coord[0] / abs(min_coord[0])))
transform_y = -int(round(min_coord[1] / abs(min_coord[1])))
machine_size = [self._global_stack.getProperty("machine_width", "value"),
self._global_stack.getProperty("machine_depth", "value")]
def flip_x(polygon):
tm2 = [-1, 0, 0, 1, 0, 0]
return affinity.affine_transform(affinity.translate(polygon, xoff = -machine_size[0]), tm2)
def flip_y(polygon):
tm2 = [1, 0, 0, -1, 0, 0]
return affinity.affine_transform(affinity.translate(polygon, yoff = -machine_size[1]), tm2)
if self._checkForCollisions():
self._node_stack = []
return
node_list = [] node_list = []
for node in self._scene_node.getChildren(): for node in self._scene_node.getChildren():
if not issubclass(type(node), SceneNode): if not issubclass(type(node), SceneNode):
continue continue
if node.callDecoration("getConvexHull"): convex_hull = node.callDecoration("getConvexHull")
node_list.append(node) if convex_hull:
xmin = min(x for x, _ in convex_hull._points)
xmax = max(x for x, _ in convex_hull._points)
ymin = min(y for _, y in convex_hull._points)
ymax = max(y for _, y in convex_hull._points)
convex_hull_polygon = Polygon.from_bounds(xmin, ymin, xmax, ymax)
if transform_x < 0:
convex_hull_polygon = flip_x(convex_hull_polygon)
if transform_y < 0:
convex_hull_polygon = flip_y(convex_hull_polygon)
if len(node_list) < 2: node_list.append({"node": node,
self._node_stack = node_list[:] "min_coord": [convex_hull_polygon.bounds[0], convex_hull_polygon.bounds[1]],
return })
# Copy the list node_list = sorted(node_list, key = lambda d: d["min_coord"])
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]
# Check if we have to files that block eachother. If this is the case, there is no solution!
for a in range(0,len(node_list)):
for b in range(0,len(node_list)):
if a != b and self._hit_map[a][b] and self._hit_map[b][a]:
return
# Sort the original list so that items that block the most other objects are at the beginning.
# This does not decrease the worst case running time, but should improve it in most cases.
sorted(node_list, key = cmp_to_key(self._calculateScore))
todo_node_list = [_ObjectOrder([], node_list)]
while len(todo_node_list) > 0:
current = todo_node_list.pop()
for node in current.todo:
# Check if the object can be placed with what we have and still allows for a solution in the future
if not self._checkHitMultiple(node, current.order) and not self._checkBlockMultiple(node, current.todo):
# We found a possible result. Create new todo & order list.
new_todo_list = current.todo[:]
new_todo_list.remove(node)
new_order = current.order[:] + [node]
if len(new_todo_list) == 0:
# We have no more nodes to check, so quit looking.
todo_node_list = None
self._node_stack = new_order
return
todo_node_list.append(_ObjectOrder(new_order, new_todo_list))
self._node_stack = [] #No result found!
# Check if first object can be printed before the provided list (using the hit map)
def _checkHitMultiple(self, node, other_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)
if self._hit_map[node_index][other_node_index]:
return True
return False
def _checkBlockMultiple(self, node, other_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)
if self._hit_map[other_node_index][node_index] and node_index != other_node_index:
return True
return False
## Calculate score simply sums the number of other objects it 'blocks'
def _calculateScore(self, a, b):
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, b):
if a == b:
return False
overlap = a.callDecoration("getConvexHullBoundary").intersectsPolygon(b.callDecoration("getConvexHullHeadFull"))
if overlap:
return True
else:
return False
## Internal object used to keep track of a possible order in which to print objects.
class _ObjectOrder():
def __init__(self, order, todo):
"""
:param order: List of indexes in which to print objects, ordered by printing order.
:param todo: List of indexes which are not yet inserted into the order list.
"""
self.order = order
self.todo = todo
self._node_stack = [d["node"] for d in node_list]

View file

@ -29,4 +29,4 @@ class PlatformPhysicsOperation(Operation):
return group return group
def __repr__(self): def __repr__(self):
return "PlatformPhysicsOperation(translation = {0})".format(self._translation) return "PlatformPhysicsOp.(trans.={0})".format(self._translation)

View file

@ -1,6 +1,9 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from UM.Application import Application
from typing import Optional, TYPE_CHECKING
from UM.Qt.QtApplication import QtApplication
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
from UM.Resources import Resources from UM.Resources import Resources
@ -10,19 +13,21 @@ from UM.View.RenderBatch import RenderBatch
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator 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. ## 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 # 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 # Note that in order to increase precision, the 24 bit depth value is encoded into all three of the R,G & B channels
class PickingPass(RenderPass): class PickingPass(RenderPass):
def __init__(self, width: int, height: int): def __init__(self, width: int, height: int) -> None:
super().__init__("picking", width, height) super().__init__("picking", width, height)
self._renderer = Application.getInstance().getRenderer() self._renderer = QtApplication.getInstance().getRenderer()
self._shader = None self._shader = None #type: Optional[ShaderProgram]
self._scene = Application.getInstance().getController().getScene() self._scene = QtApplication.getInstance().getController().getScene()
def render(self) -> None: def render(self) -> None:
if not self._shader: if not self._shader:
@ -37,7 +42,7 @@ class PickingPass(RenderPass):
batch = RenderBatch(self._shader) batch = RenderBatch(self._shader)
# Fill up the batch with objects that can be sliced. ` # Fill up the batch with objects that can be sliced. `
for node in DepthFirstIterator(self._scene.getRoot()): for node in DepthFirstIterator(self._scene.getRoot()): #type: ignore #Ignore type error because iter() should get called automatically by Python syntax.
if node.callDecoration("isSliceable") and node.getMeshData() and node.isVisible(): if node.callDecoration("isSliceable") and node.getMeshData() and node.isVisible():
batch.addItem(node.getWorldTransformation(), node.getMeshData()) batch.addItem(node.getWorldTransformation(), node.getMeshData())
@ -64,6 +69,7 @@ class PickingPass(RenderPass):
## Get the world coordinates of a picked point ## Get the world coordinates of a picked point
def getPickedPosition(self, x: int, y: int) -> Vector: def getPickedPosition(self, x: int, y: int) -> Vector:
distance = self.getPickedDepth(x, y) distance = self.getPickedDepth(x, y)
ray = self._scene.getActiveCamera().getRay(x, y) camera = self._scene.getActiveCamera()
if camera:
return ray.getPointAlongRay(distance) return camera.getRay(x, y).getPointAlongRay(distance)
return Vector()

View file

@ -8,7 +8,7 @@ from UM.Scene.SceneNode import SceneNode
from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
from UM.Scene.Selection import Selection from UM.Scene.Selection import Selection
from UM.Preferences import Preferences from UM.Scene.SceneNodeSettings import SceneNodeSettings
from cura.Scene.ConvexHullDecorator import ConvexHullDecorator from cura.Scene.ConvexHullDecorator import ConvexHullDecorator
@ -17,7 +17,6 @@ from cura.Scene import ZOffsetDecorator
import random # used for list shuffling import random # used for list shuffling
class PlatformPhysics: class PlatformPhysics:
def __init__(self, controller, volume): def __init__(self, controller, volume):
super().__init__() super().__init__()
@ -36,12 +35,13 @@ class PlatformPhysics:
self._max_overlap_checks = 10 # How many times should we try to find a new spot per tick? self._max_overlap_checks = 10 # How many times should we try to find a new spot per tick?
self._minimum_gap = 2 # It is a minimum distance (in mm) between two models, applicable for small models self._minimum_gap = 2 # It is a minimum distance (in mm) between two models, applicable for small models
Preferences.getInstance().addPreference("physics/automatic_push_free", True) Application.getInstance().getPreferences().addPreference("physics/automatic_push_free", False)
Preferences.getInstance().addPreference("physics/automatic_drop_down", True) Application.getInstance().getPreferences().addPreference("physics/automatic_drop_down", True)
def _onSceneChanged(self, source): def _onSceneChanged(self, source):
if not source.getMeshData(): if not source.callDecoration("isSliceable"):
return return
self._change_timer.start() self._change_timer.start()
def _onChangeTimerFinished(self): def _onChangeTimerFinished(self):
@ -71,7 +71,7 @@ class PlatformPhysics:
# Move it downwards if bottom is above platform # Move it downwards if bottom is above platform
move_vector = Vector() move_vector = Vector()
if Preferences.getInstance().getValue("physics/automatic_drop_down") and not (node.getParent() and node.getParent().callDecoration("isGroup") or node.getParent() != root) and node.isEnabled(): #If an object is grouped, don't move it down if Application.getInstance().getPreferences().getValue("physics/automatic_drop_down") and not (node.getParent() and node.getParent().callDecoration("isGroup") or node.getParent() != root) and node.isEnabled(): #If an object is grouped, don't move it down
z_offset = node.callDecoration("getZOffset") if node.getDecorator(ZOffsetDecorator.ZOffsetDecorator) else 0 z_offset = node.callDecoration("getZOffset") if node.getDecorator(ZOffsetDecorator.ZOffsetDecorator) else 0
move_vector = move_vector.set(y = -bbox.bottom + z_offset) move_vector = move_vector.set(y = -bbox.bottom + z_offset)
@ -80,7 +80,11 @@ class PlatformPhysics:
node.addDecorator(ConvexHullDecorator()) node.addDecorator(ConvexHullDecorator())
# only push away objects if this node is a printing mesh # only push away objects if this node is a printing mesh
if not node.callDecoration("isNonPrintingMesh") and Preferences.getInstance().getValue("physics/automatic_push_free"): if not node.callDecoration("isNonPrintingMesh") and Application.getInstance().getPreferences().getValue("physics/automatic_push_free"):
# Do not move locked nodes
if node.getSetting(SceneNodeSettings.LockPosition):
continue
# Check for collisions between convex hulls # Check for collisions between convex hulls
for other_node in BreadthFirstIterator(root): for other_node in BreadthFirstIterator(root):
# Ignore root, ourselves and anything that is not a normal SceneNode. # Ignore root, ourselves and anything that is not a normal SceneNode.

View file

@ -1,5 +1,8 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, TYPE_CHECKING
from UM.Application import Application from UM.Application import Application
from UM.Resources import Resources from UM.Resources import Resources
@ -10,7 +13,8 @@ from UM.View.RenderBatch import RenderBatch
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from typing import Optional if TYPE_CHECKING:
from UM.View.GL.ShaderProgram import ShaderProgram
MYPY = False MYPY = False
if MYPY: if MYPY:
@ -33,16 +37,16 @@ def prettier_color(color_list):
# #
# This is useful to get a preview image of a scene taken from a different location as the active camera. # This is useful to get a preview image of a scene taken from a different location as the active camera.
class PreviewPass(RenderPass): class PreviewPass(RenderPass):
def __init__(self, width: int, height: int): def __init__(self, width: int, height: int) -> None:
super().__init__("preview", width, height, 0) super().__init__("preview", width, height, 0)
self._camera = None # type: Optional[Camera] self._camera = None # type: Optional[Camera]
self._renderer = Application.getInstance().getRenderer() self._renderer = Application.getInstance().getRenderer()
self._shader = None self._shader = None #type: Optional[ShaderProgram]
self._non_printing_shader = None self._non_printing_shader = None #type: Optional[ShaderProgram]
self._support_mesh_shader = None self._support_mesh_shader = None #type: Optional[ShaderProgram]
self._scene = Application.getInstance().getController().getScene() self._scene = Application.getInstance().getController().getScene()
# Set the camera to be used by this render pass # Set the camera to be used by this render pass
@ -53,18 +57,21 @@ class PreviewPass(RenderPass):
def render(self) -> None: def render(self) -> None:
if not self._shader: if not self._shader:
self._shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "overhang.shader")) self._shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "overhang.shader"))
if self._shader:
self._shader.setUniformValue("u_overhangAngle", 1.0) self._shader.setUniformValue("u_overhangAngle", 1.0)
self._shader.setUniformValue("u_ambientColor", [0.1, 0.1, 0.1, 1.0]) self._shader.setUniformValue("u_ambientColor", [0.1, 0.1, 0.1, 1.0])
self._shader.setUniformValue("u_specularColor", [0.6, 0.6, 0.6, 1.0]) self._shader.setUniformValue("u_specularColor", [0.6, 0.6, 0.6, 1.0])
self._shader.setUniformValue("u_shininess", 20.0) self._shader.setUniformValue("u_shininess", 20.0)
if not self._non_printing_shader: if not self._non_printing_shader:
if self._non_printing_shader:
self._non_printing_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader")) self._non_printing_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "transparent_object.shader"))
self._non_printing_shader.setUniformValue("u_diffuseColor", [0.5, 0.5, 0.5, 0.5]) self._non_printing_shader.setUniformValue("u_diffuseColor", [0.5, 0.5, 0.5, 0.5])
self._non_printing_shader.setUniformValue("u_opacity", 0.6) self._non_printing_shader.setUniformValue("u_opacity", 0.6)
if not self._support_mesh_shader: if not self._support_mesh_shader:
self._support_mesh_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "striped.shader")) self._support_mesh_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "striped.shader"))
if self._support_mesh_shader:
self._support_mesh_shader.setUniformValue("u_vertical_stripes", True) self._support_mesh_shader.setUniformValue("u_vertical_stripes", True)
self._support_mesh_shader.setUniformValue("u_width", 5.0) self._support_mesh_shader.setUniformValue("u_width", 5.0)
@ -75,14 +82,14 @@ class PreviewPass(RenderPass):
batch = RenderBatch(self._shader) batch = RenderBatch(self._shader)
batch_support_mesh = RenderBatch(self._support_mesh_shader) batch_support_mesh = RenderBatch(self._support_mesh_shader)
# Fill up the batch with objects that can be sliced. ` # Fill up the batch with objects that can be sliced.
for node in DepthFirstIterator(self._scene.getRoot()): for node in DepthFirstIterator(self._scene.getRoot()): #type: ignore #Ignore type error because iter() should get called automatically by Python syntax.
if node.callDecoration("isSliceable") and node.getMeshData() and node.isVisible(): if node.callDecoration("isSliceable") and node.getMeshData() and node.isVisible():
per_mesh_stack = node.callDecoration("getStack") per_mesh_stack = node.callDecoration("getStack")
if node.callDecoration("isNonPrintingMesh"): if node.callDecoration("isNonThumbnailVisibleMesh"):
# Non printing mesh # Non printing mesh
continue continue
elif per_mesh_stack is not None and per_mesh_stack.getProperty("support_mesh", "value") == True: elif per_mesh_stack is not None and per_mesh_stack.getProperty("support_mesh", "value"):
# Support mesh # Support mesh
uniforms = {} uniforms = {}
shade_factor = 0.6 shade_factor = 0.6
@ -112,4 +119,3 @@ class PreviewPass(RenderPass):
batch_support_mesh.render(render_camera) batch_support_mesh.render(render_camera)
self.release() self.release()

View file

@ -0,0 +1,27 @@
from PyQt5.QtGui import QImage
from PyQt5.QtQuick import QQuickImageProvider
from PyQt5.QtCore import QSize
from UM.Application import Application
class PrintJobPreviewImageProvider(QQuickImageProvider):
def __init__(self):
super().__init__(QQuickImageProvider.Image)
## Request a new image.
def requestImage(self, id: str, size: QSize) -> QImage:
# 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:]
for output_device in Application.getInstance().getOutputDeviceManager().getOutputDevices():
if not hasattr(output_device, "printJobs"):
continue
for print_job in output_device.printJobs:
if print_job.key == uuid:
if print_job.getPreviewImage():
return print_job.getPreviewImage(), QSize(15, 15)
else:
return QImage(), QSize(15, 15)
return QImage(), QSize(15,15)

View file

@ -0,0 +1,78 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import QObject, QUrl, pyqtSignal, pyqtProperty
from enum import IntEnum
from threading import Thread
from typing import Union
MYPY = False
if MYPY:
from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
class FirmwareUpdater(QObject):
firmwareProgressChanged = pyqtSignal()
firmwareUpdateStateChanged = pyqtSignal()
def __init__(self, output_device: "PrinterOutputDevice") -> None:
super().__init__()
self._output_device = output_device
self._update_firmware_thread = Thread(target=self._updateFirmware, daemon=True)
self._firmware_file = ""
self._firmware_progress = 0
self._firmware_update_state = FirmwareUpdateState.idle
def updateFirmware(self, firmware_file: Union[str, QUrl]) -> None:
# the file path could be url-encoded.
if firmware_file.startswith("file://"):
self._firmware_file = QUrl(firmware_file).toLocalFile()
else:
self._firmware_file = firmware_file
self._setFirmwareUpdateState(FirmwareUpdateState.updating)
self._update_firmware_thread.start()
def _updateFirmware(self) -> None:
raise NotImplementedError("_updateFirmware needs to be implemented")
## Cleanup after a succesful update
def _cleanupAfterUpdate(self) -> None:
# Clean up for next attempt.
self._update_firmware_thread = Thread(target=self._updateFirmware, daemon=True)
self._firmware_file = ""
self._onFirmwareProgress(100)
self._setFirmwareUpdateState(FirmwareUpdateState.completed)
@pyqtProperty(int, notify = firmwareProgressChanged)
def firmwareProgress(self) -> int:
return self._firmware_progress
@pyqtProperty(int, notify=firmwareUpdateStateChanged)
def firmwareUpdateState(self) -> "FirmwareUpdateState":
return self._firmware_update_state
def _setFirmwareUpdateState(self, state: "FirmwareUpdateState") -> None:
if self._firmware_update_state != state:
self._firmware_update_state = state
self.firmwareUpdateStateChanged.emit()
# Callback function for firmware update progress.
def _onFirmwareProgress(self, progress: int, max_progress: int = 100) -> None:
self._firmware_progress = int(progress * 100 / max_progress) # Convert to scale of 0-100
self.firmwareProgressChanged.emit()
class FirmwareUpdateState(IntEnum):
idle = 0
updating = 1
completed = 2
unknown_error = 3
communication_error = 4
io_error = 5
firmware_not_found_error = 6

View file

@ -1,33 +1,37 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from typing import TYPE_CHECKING, Set, Union, Optional
from PyQt5.QtCore import QTimer from PyQt5.QtCore import QTimer
MYPY = False from .PrinterOutputController import PrinterOutputController
if MYPY:
from cura.PrinterOutput.PrintJobOutputModel import PrintJobOutputModel if TYPE_CHECKING:
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from .Models.PrintJobOutputModel import PrintJobOutputModel
from .Models.PrinterOutputModel import PrinterOutputModel
from .PrinterOutputDevice import PrinterOutputDevice
from .Models.ExtruderOutputModel import ExtruderOutputModel
class GenericOutputController(PrinterOutputController): class GenericOutputController(PrinterOutputController):
def __init__(self, output_device): def __init__(self, output_device: "PrinterOutputDevice") -> None:
super().__init__(output_device) super().__init__(output_device)
self._preheat_bed_timer = QTimer() self._preheat_bed_timer = QTimer()
self._preheat_bed_timer.setSingleShot(True) self._preheat_bed_timer.setSingleShot(True)
self._preheat_bed_timer.timeout.connect(self._onPreheatBedTimerFinished) self._preheat_bed_timer.timeout.connect(self._onPreheatBedTimerFinished)
self._preheat_printer = None self._preheat_printer = None # type: Optional[PrinterOutputModel]
self._preheat_hotends_timer = QTimer() self._preheat_hotends_timer = QTimer()
self._preheat_hotends_timer.setSingleShot(True) self._preheat_hotends_timer.setSingleShot(True)
self._preheat_hotends_timer.timeout.connect(self._onPreheatHotendsTimerFinished) self._preheat_hotends_timer.timeout.connect(self._onPreheatHotendsTimerFinished)
self._preheat_hotends = set() self._preheat_hotends = set() # type: Set[ExtruderOutputModel]
self._output_device.printersChanged.connect(self._onPrintersChanged) self._output_device.printersChanged.connect(self._onPrintersChanged)
self._active_printer = None self._active_printer = None # type: Optional[PrinterOutputModel]
def _onPrintersChanged(self): def _onPrintersChanged(self) -> None:
if self._active_printer: if self._active_printer:
self._active_printer.stateChanged.disconnect(self._onPrinterStateChanged) self._active_printer.stateChanged.disconnect(self._onPrinterStateChanged)
self._active_printer.targetBedTemperatureChanged.disconnect(self._onTargetBedTemperatureChanged) self._active_printer.targetBedTemperatureChanged.disconnect(self._onTargetBedTemperatureChanged)
@ -41,32 +45,33 @@ class GenericOutputController(PrinterOutputController):
for extruder in self._active_printer.extruders: for extruder in self._active_printer.extruders:
extruder.targetHotendTemperatureChanged.connect(self._onTargetHotendTemperatureChanged) extruder.targetHotendTemperatureChanged.connect(self._onTargetHotendTemperatureChanged)
def _onPrinterStateChanged(self): def _onPrinterStateChanged(self) -> None:
if self._active_printer.state != "idle": if self._active_printer and self._active_printer.state != "idle":
if self._preheat_bed_timer.isActive(): if self._preheat_bed_timer.isActive():
self._preheat_bed_timer.stop() self._preheat_bed_timer.stop()
if self._preheat_printer:
self._preheat_printer.updateIsPreheating(False) self._preheat_printer.updateIsPreheating(False)
if self._preheat_hotends_timer.isActive(): if self._preheat_hotends_timer.isActive():
self._preheat_hotends_timer.stop() self._preheat_hotends_timer.stop()
for extruder in self._preheat_hotends: for extruder in self._preheat_hotends:
extruder.updateIsPreheating(False) extruder.updateIsPreheating(False)
self._preheat_hotends = set() self._preheat_hotends = set() # type: Set[ExtruderOutputModel]
def moveHead(self, printer: "PrinterOutputModel", x, y, z, speed): def moveHead(self, printer: "PrinterOutputModel", x, y, z, speed) -> None:
self._output_device.sendCommand("G91") self._output_device.sendCommand("G91")
self._output_device.sendCommand("G0 X%s Y%s Z%s F%s" % (x, y, z, speed)) self._output_device.sendCommand("G0 X%s Y%s Z%s F%s" % (x, y, z, speed))
self._output_device.sendCommand("G90") self._output_device.sendCommand("G90")
def homeHead(self, printer): def homeHead(self, printer: "PrinterOutputModel") -> None:
self._output_device.sendCommand("G28 X Y") self._output_device.sendCommand("G28 X Y")
def homeBed(self, printer): def homeBed(self, printer: "PrinterOutputModel") -> None:
self._output_device.sendCommand("G28 Z") self._output_device.sendCommand("G28 Z")
def sendRawCommand(self, printer: "PrinterOutputModel", command: str): def sendRawCommand(self, printer: "PrinterOutputModel", command: str) -> None:
self._output_device.sendCommand(command) self._output_device.sendCommand(command.upper()) #Most printers only understand uppercase g-code commands.
def setJobState(self, job: "PrintJobOutputModel", state: str): def setJobState(self, job: "PrintJobOutputModel", state: str) -> None:
if state == "pause": if state == "pause":
self._output_device.pausePrint() self._output_device.pausePrint()
job.updateState("paused") job.updateState("paused")
@ -77,42 +82,46 @@ class GenericOutputController(PrinterOutputController):
self._output_device.cancelPrint() self._output_device.cancelPrint()
pass pass
def setTargetBedTemperature(self, printer: "PrinterOutputModel", temperature: int): def setTargetBedTemperature(self, printer: "PrinterOutputModel", temperature: float) -> None:
self._output_device.sendCommand("M140 S%s" % temperature) self._output_device.sendCommand("M140 S%s" % round(temperature)) # The API doesn't allow floating point.
def _onTargetBedTemperatureChanged(self): def _onTargetBedTemperatureChanged(self) -> None:
if self._preheat_bed_timer.isActive() and self._preheat_printer.targetBedTemperature == 0: if self._preheat_bed_timer.isActive() and self._preheat_printer and self._preheat_printer.targetBedTemperature == 0:
self._preheat_bed_timer.stop() self._preheat_bed_timer.stop()
self._preheat_printer.updateIsPreheating(False) self._preheat_printer.updateIsPreheating(False)
def preheatBed(self, printer: "PrinterOutputModel", temperature, duration): def preheatBed(self, printer: "PrinterOutputModel", temperature, duration) -> None:
try: try:
temperature = round(temperature) # The API doesn't allow floating point. temperature = round(temperature) # The API doesn't allow floating point.
duration = round(duration) duration = round(duration)
except ValueError: except ValueError:
return # Got invalid values, can't pre-heat. return # Got invalid values, can't pre-heat.
self.setTargetBedTemperature(printer, temperature=temperature) self.setTargetBedTemperature(printer, temperature = temperature)
self._preheat_bed_timer.setInterval(duration * 1000) self._preheat_bed_timer.setInterval(duration * 1000)
self._preheat_bed_timer.start() self._preheat_bed_timer.start()
self._preheat_printer = printer self._preheat_printer = printer
printer.updateIsPreheating(True) printer.updateIsPreheating(True)
def cancelPreheatBed(self, printer: "PrinterOutputModel"): def cancelPreheatBed(self, printer: "PrinterOutputModel") -> None:
self.setTargetBedTemperature(printer, temperature=0) self.setTargetBedTemperature(printer, temperature = 0)
self._preheat_bed_timer.stop() self._preheat_bed_timer.stop()
printer.updateIsPreheating(False) printer.updateIsPreheating(False)
def _onPreheatBedTimerFinished(self): def _onPreheatBedTimerFinished(self) -> None:
if not self._preheat_printer:
return
self.setTargetBedTemperature(self._preheat_printer, 0) self.setTargetBedTemperature(self._preheat_printer, 0)
self._preheat_printer.updateIsPreheating(False) self._preheat_printer.updateIsPreheating(False)
def setTargetHotendTemperature(self, printer: "PrinterOutputModel", position: int, temperature: int): def setTargetHotendTemperature(self, printer: "PrinterOutputModel", position: int, temperature: Union[int, float]) -> None:
self._output_device.sendCommand("M104 S%s T%s" % (temperature, position)) self._output_device.sendCommand("M104 S%s T%s" % (temperature, position))
def _onTargetHotendTemperatureChanged(self): def _onTargetHotendTemperatureChanged(self) -> None:
if not self._preheat_hotends_timer.isActive(): if not self._preheat_hotends_timer.isActive():
return return
if not self._active_printer:
return
for extruder in self._active_printer.extruders: for extruder in self._active_printer.extruders:
if extruder in self._preheat_hotends and extruder.targetHotendTemperature == 0: if extruder in self._preheat_hotends and extruder.targetHotendTemperature == 0:
@ -121,7 +130,7 @@ class GenericOutputController(PrinterOutputController):
if not self._preheat_hotends: if not self._preheat_hotends:
self._preheat_hotends_timer.stop() self._preheat_hotends_timer.stop()
def preheatHotend(self, extruder: "ExtruderOutputModel", temperature, duration): def preheatHotend(self, extruder: "ExtruderOutputModel", temperature, duration) -> None:
position = extruder.getPosition() position = extruder.getPosition()
number_of_extruders = len(extruder.getPrinter().extruders) number_of_extruders = len(extruder.getPrinter().extruders)
if position >= number_of_extruders: if position >= number_of_extruders:
@ -139,7 +148,7 @@ class GenericOutputController(PrinterOutputController):
self._preheat_hotends.add(extruder) self._preheat_hotends.add(extruder)
extruder.updateIsPreheating(True) extruder.updateIsPreheating(True)
def cancelPreheatHotend(self, extruder: "ExtruderOutputModel"): def cancelPreheatHotend(self, extruder: "ExtruderOutputModel") -> None:
self.setTargetHotendTemperature(extruder.getPrinter(), extruder.getPosition(), temperature=0) self.setTargetHotendTemperature(extruder.getPrinter(), extruder.getPosition(), temperature=0)
if extruder in self._preheat_hotends: if extruder in self._preheat_hotends:
extruder.updateIsPreheating(False) extruder.updateIsPreheating(False)
@ -147,21 +156,22 @@ class GenericOutputController(PrinterOutputController):
if not self._preheat_hotends and self._preheat_hotends_timer.isActive(): if not self._preheat_hotends and self._preheat_hotends_timer.isActive():
self._preheat_hotends_timer.stop() self._preheat_hotends_timer.stop()
def _onPreheatHotendsTimerFinished(self): def _onPreheatHotendsTimerFinished(self) -> None:
for extruder in self._preheat_hotends: for extruder in self._preheat_hotends:
self.setTargetHotendTemperature(extruder.getPrinter(), extruder.getPosition(), 0) self.setTargetHotendTemperature(extruder.getPrinter(), extruder.getPosition(), 0)
self._preheat_hotends = set() self._preheat_hotends = set() #type: Set[ExtruderOutputModel]
# Cancel any ongoing preheating timers, without setting back the temperature to 0 # Cancel any ongoing preheating timers, without setting back the temperature to 0
# This can be used eg at the start of a print # This can be used eg at the start of a print
def stopPreheatTimers(self): def stopPreheatTimers(self) -> None:
if self._preheat_hotends_timer.isActive(): if self._preheat_hotends_timer.isActive():
for extruder in self._preheat_hotends: for extruder in self._preheat_hotends:
extruder.updateIsPreheating(False) extruder.updateIsPreheating(False)
self._preheat_hotends = set() self._preheat_hotends = set() #type: Set[ExtruderOutputModel]
self._preheat_hotends_timer.stop() self._preheat_hotends_timer.stop()
if self._preheat_bed_timer.isActive(): if self._preheat_bed_timer.isActive():
if self._preheat_printer:
self._preheat_printer.updateIsPreheating(False) self._preheat_printer.updateIsPreheating(False)
self._preheat_bed_timer.stop() self._preheat_bed_timer.stop()

View file

@ -1,34 +0,0 @@
# Copyright (c) 2017 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, QVariant, pyqtSlot
class MaterialOutputModel(QObject):
def __init__(self, guid, type, color, brand, name, parent = None):
super().__init__(parent)
self._guid = guid
self._type = type
self._color = color
self._brand = brand
self._name = name
@pyqtProperty(str, constant = True)
def guid(self):
return self._guid
@pyqtProperty(str, constant=True)
def type(self):
return self._type
@pyqtProperty(str, constant=True)
def brand(self):
return self._brand
@pyqtProperty(str, constant=True)
def color(self):
return self._color
@pyqtProperty(str, constant=True)
def name(self):
return self._name

View file

@ -1,56 +1,67 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
from PyQt5.QtCore import pyqtProperty, QObject, pyqtSignal from PyQt5.QtCore import pyqtProperty, QObject, pyqtSignal
from .MaterialOutputModel import MaterialOutputModel
class ExtruderConfigurationModel(QObject): class ExtruderConfigurationModel(QObject):
extruderConfigurationChanged = pyqtSignal() extruderConfigurationChanged = pyqtSignal()
def __init__(self): def __init__(self, position: int = -1) -> None:
super().__init__() super().__init__()
self._position = -1 self._position = position # type: int
self._material = None self._material = None # type: Optional[MaterialOutputModel]
self._hotend_id = None self._hotend_id = None # type: Optional[str]
def setPosition(self, position): def setPosition(self, position: int) -> None:
self._position = position self._position = position
@pyqtProperty(int, fset = setPosition, notify = extruderConfigurationChanged) @pyqtProperty(int, fset = setPosition, notify = extruderConfigurationChanged)
def position(self): def position(self) -> int:
return self._position return self._position
def setMaterial(self, material): def setMaterial(self, material: Optional[MaterialOutputModel]) -> None:
if self._hotend_id != material:
self._material = material self._material = material
self.extruderConfigurationChanged.emit()
@pyqtProperty(QObject, fset = setMaterial, notify = extruderConfigurationChanged) @pyqtProperty(QObject, fset = setMaterial, notify = extruderConfigurationChanged)
def material(self): def activeMaterial(self) -> Optional[MaterialOutputModel]:
return self._material return self._material
def setHotendID(self, hotend_id): @pyqtProperty(QObject, fset=setMaterial, notify=extruderConfigurationChanged)
def material(self) -> Optional[MaterialOutputModel]:
return self._material
def setHotendID(self, hotend_id: Optional[str]) -> None:
if self._hotend_id != hotend_id:
self._hotend_id = hotend_id self._hotend_id = hotend_id
self.extruderConfigurationChanged.emit()
@pyqtProperty(str, fset = setHotendID, notify = extruderConfigurationChanged) @pyqtProperty(str, fset = setHotendID, notify = extruderConfigurationChanged)
def hotendID(self): def hotendID(self) -> Optional[str]:
return self._hotend_id return self._hotend_id
## This method is intended to indicate whether the configuration is valid or not. ## This method is intended to indicate whether the configuration is valid or not.
# The method checks if the mandatory fields are or not set # 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. # At this moment is always valid since we allow to have empty material and variants.
def isValid(self): def isValid(self) -> bool:
return True return True
def __str__(self): def __str__(self) -> str:
message_chunks = [] message_chunks = []
message_chunks.append("Position: " + str(self._position)) message_chunks.append("Position: " + str(self._position))
message_chunks.append("-") message_chunks.append("-")
message_chunks.append("Material: " + self.material.type if self.material else "empty") message_chunks.append("Material: " + self.activeMaterial.type if self.activeMaterial else "empty")
message_chunks.append("-") message_chunks.append("-")
message_chunks.append("HotendID: " + self.hotendID if self.hotendID else "empty") message_chunks.append("HotendID: " + self.hotendID if self.hotendID else "empty")
return " ".join(message_chunks) return " ".join(message_chunks)
def __eq__(self, other): def __eq__(self, other) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
# Calculating a hash function using the position of the extruder, the material GUID and the hotend id to check if is # Calculating a hash function using the position of the extruder, the material GUID and the hotend id to check if is

View file

@ -1,76 +1,73 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, TYPE_CHECKING
from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, pyqtSlot from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, pyqtSlot
from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationModel
from typing import Optional from .ExtruderConfigurationModel import ExtruderConfigurationModel
MYPY = False if TYPE_CHECKING:
if MYPY: from .MaterialOutputModel import MaterialOutputModel
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from .PrinterOutputModel import PrinterOutputModel
from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel
class ExtruderOutputModel(QObject): class ExtruderOutputModel(QObject):
hotendIDChanged = pyqtSignal()
targetHotendTemperatureChanged = pyqtSignal() targetHotendTemperatureChanged = pyqtSignal()
hotendTemperatureChanged = pyqtSignal() hotendTemperatureChanged = pyqtSignal()
activeMaterialChanged = pyqtSignal()
extruderConfigurationChanged = pyqtSignal() extruderConfigurationChanged = pyqtSignal()
isPreheatingChanged = pyqtSignal() isPreheatingChanged = pyqtSignal()
def __init__(self, printer: "PrinterOutputModel", position, parent=None): def __init__(self, printer: "PrinterOutputModel", position: int, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
self._printer = printer self._printer = printer # type: PrinterOutputModel
self._position = position self._position = position
self._target_hotend_temperature = 0 self._target_hotend_temperature = 0.0 # type: float
self._hotend_temperature = 0 self._hotend_temperature = 0.0 # type: float
self._hotend_id = ""
self._active_material = None # type: Optional[MaterialOutputModel]
self._extruder_configuration = ExtruderConfigurationModel()
self._extruder_configuration.position = self._position
self._is_preheating = False self._is_preheating = False
def getPrinter(self): # The extruder output model wraps the configuration model. This way we can use the same config model for jobs
# and extruders alike.
self._extruder_configuration = ExtruderConfigurationModel()
self._extruder_configuration.position = self._position
self._extruder_configuration.extruderConfigurationChanged.connect(self.extruderConfigurationChanged)
def getPrinter(self) -> "PrinterOutputModel":
return self._printer return self._printer
def getPosition(self): def getPosition(self) -> int:
return self._position return self._position
# Does the printer support pre-heating the bed at all # Does the printer support pre-heating the bed at all
@pyqtProperty(bool, constant=True) @pyqtProperty(bool, constant=True)
def canPreHeatHotends(self): def canPreHeatHotends(self) -> bool:
if self._printer: if self._printer:
return self._printer.canPreHeatHotends return self._printer.canPreHeatHotends
return False return False
@pyqtProperty(QObject, notify = activeMaterialChanged) @pyqtProperty(QObject, notify = extruderConfigurationChanged)
def activeMaterial(self) -> "MaterialOutputModel": def activeMaterial(self) -> Optional["MaterialOutputModel"]:
return self._active_material return self._extruder_configuration.activeMaterial
def updateActiveMaterial(self, material: Optional["MaterialOutputModel"]): def updateActiveMaterial(self, material: Optional["MaterialOutputModel"]) -> None:
if self._active_material != material: self._extruder_configuration.setMaterial(material)
self._active_material = material
self._extruder_configuration.material = self._active_material
self.activeMaterialChanged.emit()
self.extruderConfigurationChanged.emit()
## Update the hotend temperature. This only changes it locally. ## Update the hotend temperature. This only changes it locally.
def updateHotendTemperature(self, temperature: float): def updateHotendTemperature(self, temperature: float) -> None:
if self._hotend_temperature != temperature: if self._hotend_temperature != temperature:
self._hotend_temperature = temperature self._hotend_temperature = temperature
self.hotendTemperatureChanged.emit() self.hotendTemperatureChanged.emit()
def updateTargetHotendTemperature(self, temperature: float): def updateTargetHotendTemperature(self, temperature: float) -> None:
if self._target_hotend_temperature != temperature: if self._target_hotend_temperature != temperature:
self._target_hotend_temperature = temperature self._target_hotend_temperature = temperature
self.targetHotendTemperatureChanged.emit() self.targetHotendTemperatureChanged.emit()
## Set the target hotend temperature. This ensures that it's actually sent to the remote. ## Set the target hotend temperature. This ensures that it's actually sent to the remote.
@pyqtSlot(float) @pyqtSlot(float)
def setTargetHotendTemperature(self, temperature: float): def setTargetHotendTemperature(self, temperature: float) -> None:
self._printer.getController().setTargetHotendTemperature(self._printer, self, temperature) self._printer.getController().setTargetHotendTemperature(self._printer, self, temperature)
self.updateTargetHotendTemperature(temperature) self.updateTargetHotendTemperature(temperature)
@ -82,30 +79,26 @@ class ExtruderOutputModel(QObject):
def hotendTemperature(self) -> float: def hotendTemperature(self) -> float:
return self._hotend_temperature return self._hotend_temperature
@pyqtProperty(str, notify = hotendIDChanged) @pyqtProperty(str, notify = extruderConfigurationChanged)
def hotendID(self) -> str: def hotendID(self) -> str:
return self._hotend_id return self._extruder_configuration.hotendID
def updateHotendID(self, id: str): def updateHotendID(self, hotend_id: str) -> None:
if self._hotend_id != id: self._extruder_configuration.setHotendID(hotend_id)
self._hotend_id = id
self._extruder_configuration.hotendID = self._hotend_id
self.hotendIDChanged.emit()
self.extruderConfigurationChanged.emit()
@pyqtProperty(QObject, notify = extruderConfigurationChanged) @pyqtProperty(QObject, notify = extruderConfigurationChanged)
def extruderConfiguration(self): def extruderConfiguration(self) -> Optional[ExtruderConfigurationModel]:
if self._extruder_configuration.isValid(): if self._extruder_configuration.isValid():
return self._extruder_configuration return self._extruder_configuration
return None return None
def updateIsPreheating(self, pre_heating): def updateIsPreheating(self, pre_heating: bool) -> None:
if self._is_preheating != pre_heating: if self._is_preheating != pre_heating:
self._is_preheating = pre_heating self._is_preheating = pre_heating
self.isPreheatingChanged.emit() self.isPreheatingChanged.emit()
@pyqtProperty(bool, notify=isPreheatingChanged) @pyqtProperty(bool, notify=isPreheatingChanged)
def isPreheating(self): def isPreheating(self) -> bool:
return self._is_preheating return self._is_preheating
## Pre-heats the extruder before printer. ## Pre-heats the extruder before printer.
@ -114,9 +107,9 @@ class ExtruderOutputModel(QObject):
# Celsius. # Celsius.
# \param duration How long the bed should stay warm, in seconds. # \param duration How long the bed should stay warm, in seconds.
@pyqtSlot(float, float) @pyqtSlot(float, float)
def preheatHotend(self, temperature, duration): def preheatHotend(self, temperature: float, duration: float) -> None:
self._printer._controller.preheatHotend(self, temperature, duration) self._printer._controller.preheatHotend(self, temperature, duration)
@pyqtSlot() @pyqtSlot()
def cancelPreheatHotend(self): def cancelPreheatHotend(self) -> None:
self._printer._controller.cancelPreheatHotend(self) self._printer._controller.cancelPreheatHotend(self)

View file

@ -0,0 +1,36 @@
# Copyright (c) 2017 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
from PyQt5.QtCore import pyqtProperty, QObject
class MaterialOutputModel(QObject):
def __init__(self, guid: Optional[str], type: str, color: str, brand: str, name: str, parent = None) -> None:
super().__init__(parent)
self._guid = guid
self._type = type
self._color = color
self._brand = brand
self._name = name
@pyqtProperty(str, constant = True)
def guid(self) -> str:
return self._guid if self._guid else ""
@pyqtProperty(str, constant = True)
def type(self) -> str:
return self._type
@pyqtProperty(str, constant = True)
def brand(self) -> str:
return self._brand
@pyqtProperty(str, constant = True)
def color(self) -> str:
return self._color
@pyqtProperty(str, constant = True)
def name(self) -> str:
return self._name

View file

@ -0,0 +1,171 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, TYPE_CHECKING, List
from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, pyqtSlot, QUrl
from PyQt5.QtGui import QImage
if TYPE_CHECKING:
from cura.PrinterOutput.PrinterOutputController import PrinterOutputController
from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel
from cura.PrinterOutput.Models.PrinterConfigurationModel import PrinterConfigurationModel
class PrintJobOutputModel(QObject):
stateChanged = pyqtSignal()
timeTotalChanged = pyqtSignal()
timeElapsedChanged = pyqtSignal()
nameChanged = pyqtSignal()
keyChanged = pyqtSignal()
assignedPrinterChanged = pyqtSignal()
ownerChanged = pyqtSignal()
configurationChanged = pyqtSignal()
previewImageChanged = pyqtSignal()
compatibleMachineFamiliesChanged = pyqtSignal()
def __init__(self, output_controller: "PrinterOutputController", key: str = "", name: str = "", parent = None) -> None:
super().__init__(parent)
self._output_controller = output_controller
self._state = ""
self._time_total = 0
self._time_elapsed = 0
self._name = name # Human readable name
self._key = key # Unique identifier
self._assigned_printer = None # type: Optional[PrinterOutputModel]
self._owner = "" # Who started/owns the print job?
self._configuration = None # type: Optional[PrinterConfigurationModel]
self._compatible_machine_families = [] # type: List[str]
self._preview_image_id = 0
self._preview_image = None # type: Optional[QImage]
@pyqtProperty("QStringList", notify=compatibleMachineFamiliesChanged)
def compatibleMachineFamilies(self):
# Hack; Some versions of cluster will return a family more than once...
return list(set(self._compatible_machine_families))
def setCompatibleMachineFamilies(self, compatible_machine_families: List[str]) -> None:
if self._compatible_machine_families != compatible_machine_families:
self._compatible_machine_families = compatible_machine_families
self.compatibleMachineFamiliesChanged.emit()
@pyqtProperty(QUrl, notify=previewImageChanged)
def previewImageUrl(self):
self._preview_image_id += 1
# There is an image provider that is called "print_job_preview". In order to ensure that the image qml object, that
# requires a QUrl to function, updates correctly we add an increasing number. This causes to see the QUrl
# as new (instead of relying on cached version and thus forces an update.
temp = "image://print_job_preview/" + str(self._preview_image_id) + "/" + self._key
return QUrl(temp, QUrl.TolerantMode)
def getPreviewImage(self) -> Optional[QImage]:
return self._preview_image
def updatePreviewImage(self, preview_image: Optional[QImage]) -> None:
if self._preview_image != preview_image:
self._preview_image = preview_image
self.previewImageChanged.emit()
@pyqtProperty(QObject, notify=configurationChanged)
def configuration(self) -> Optional["PrinterConfigurationModel"]:
return self._configuration
def updateConfiguration(self, configuration: Optional["PrinterConfigurationModel"]) -> None:
if self._configuration != configuration:
self._configuration = configuration
self.configurationChanged.emit()
@pyqtProperty(str, notify=ownerChanged)
def owner(self):
return self._owner
def updateOwner(self, owner):
if self._owner != owner:
self._owner = owner
self.ownerChanged.emit()
@pyqtProperty(QObject, notify=assignedPrinterChanged)
def assignedPrinter(self):
return self._assigned_printer
def updateAssignedPrinter(self, assigned_printer: Optional["PrinterOutputModel"]) -> None:
if self._assigned_printer != assigned_printer:
old_printer = self._assigned_printer
self._assigned_printer = assigned_printer
if old_printer is not None:
# If the previously assigned printer is set, this job is moved away from it.
old_printer.updateActivePrintJob(None)
self.assignedPrinterChanged.emit()
@pyqtProperty(str, notify=keyChanged)
def key(self):
return self._key
def updateKey(self, key: str):
if self._key != key:
self._key = key
self.keyChanged.emit()
@pyqtProperty(str, notify = nameChanged)
def name(self):
return self._name
def updateName(self, name: str):
if self._name != name:
self._name = name
self.nameChanged.emit()
@pyqtProperty(int, notify = timeTotalChanged)
def timeTotal(self) -> int:
return self._time_total
@pyqtProperty(int, notify = timeElapsedChanged)
def timeElapsed(self) -> int:
return self._time_elapsed
@pyqtProperty(int, notify = timeElapsedChanged)
def timeRemaining(self) -> int:
# Never get a negative time remaining
return max(self.timeTotal - self.timeElapsed, 0)
@pyqtProperty(float, notify = timeElapsedChanged)
def progress(self) -> float:
result = float(self.timeElapsed) / max(self.timeTotal, 1.0) # Prevent a division by zero exception.
return min(result, 1.0) # Never get a progress past 1.0
@pyqtProperty(str, notify=stateChanged)
def state(self) -> str:
return self._state
@pyqtProperty(bool, notify=stateChanged)
def isActive(self) -> bool:
inactive_states = [
"pausing",
"paused",
"resuming",
"wait_cleanup"
]
if self.state in inactive_states and self.timeRemaining > 0:
return False
return True
def updateTimeTotal(self, new_time_total):
if self._time_total != new_time_total:
self._time_total = new_time_total
self.timeTotalChanged.emit()
def updateTimeElapsed(self, new_time_elapsed):
if self._time_elapsed != new_time_elapsed:
self._time_elapsed = new_time_elapsed
self.timeElapsedChanged.emit()
def updateState(self, new_state):
if self._state != new_state:
self._state = new_state
self.stateChanged.emit()
@pyqtSlot(str)
def setState(self, state):
self._output_controller.setJobState(self, state)

View file

@ -6,49 +6,57 @@ from typing import List
MYPY = False MYPY = False
if MYPY: if MYPY:
from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationModel from cura.PrinterOutput.Models.ExtruderConfigurationModel import ExtruderConfigurationModel
class ConfigurationModel(QObject): class PrinterConfigurationModel(QObject):
configurationChanged = pyqtSignal() configurationChanged = pyqtSignal()
def __init__(self): def __init__(self) -> None:
super().__init__() super().__init__()
self._printer_type = None self._printer_type = ""
self._extruder_configurations = [] # type: List[ExtruderConfigurationModel] self._extruder_configurations = [] # type: List[ExtruderConfigurationModel]
self._buildplate_configuration = None self._buildplate_configuration = ""
def setPrinterType(self, printer_type): def setPrinterType(self, printer_type: str) -> None:
self._printer_type = printer_type self._printer_type = printer_type
@pyqtProperty(str, fset = setPrinterType, notify = configurationChanged) @pyqtProperty(str, fset = setPrinterType, notify = configurationChanged)
def printerType(self): def printerType(self) -> str:
return self._printer_type return self._printer_type
def setExtruderConfigurations(self, extruder_configurations): def setExtruderConfigurations(self, extruder_configurations: List["ExtruderConfigurationModel"]) -> None:
if self._extruder_configurations != extruder_configurations:
self._extruder_configurations = extruder_configurations self._extruder_configurations = extruder_configurations
for extruder_configuration in self._extruder_configurations:
extruder_configuration.extruderConfigurationChanged.connect(self.configurationChanged)
self.configurationChanged.emit()
@pyqtProperty("QVariantList", fset = setExtruderConfigurations, notify = configurationChanged) @pyqtProperty("QVariantList", fset = setExtruderConfigurations, notify = configurationChanged)
def extruderConfigurations(self): def extruderConfigurations(self):
return self._extruder_configurations return self._extruder_configurations
def setBuildplateConfiguration(self, buildplate_configuration): def setBuildplateConfiguration(self, buildplate_configuration: str) -> None:
if self._buildplate_configuration != buildplate_configuration:
self._buildplate_configuration = buildplate_configuration self._buildplate_configuration = buildplate_configuration
self.configurationChanged.emit()
@pyqtProperty(str, fset = setBuildplateConfiguration, notify = configurationChanged) @pyqtProperty(str, fset = setBuildplateConfiguration, notify = configurationChanged)
def buildplateConfiguration(self): def buildplateConfiguration(self) -> str:
return self._buildplate_configuration return self._buildplate_configuration
## This method is intended to indicate whether the configuration is valid or not. ## This method is intended to indicate whether the configuration is valid or not.
# The method checks if the mandatory fields are or not set # The method checks if the mandatory fields are or not set
def isValid(self): def isValid(self) -> bool:
if not self._extruder_configurations: if not self._extruder_configurations:
return False return False
for configuration in self._extruder_configurations: for configuration in self._extruder_configurations:
if configuration is None: if configuration is None:
return False return False
return self._printer_type is not None return self._printer_type != ""
def __str__(self): def __str__(self):
message_chunks = [] message_chunks = []

View file

@ -1,15 +1,15 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, QVariant, pyqtSlot from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, QVariant, pyqtSlot, QUrl
from typing import Optional from typing import List, Dict, Optional
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
from cura.PrinterOutput.ConfigurationModel import ConfigurationModel from cura.PrinterOutput.Models.PrinterConfigurationModel import PrinterConfigurationModel
from cura.PrinterOutput.ExtruderOutputModel import ExtruderOutputModel from cura.PrinterOutput.Models.ExtruderOutputModel import ExtruderOutputModel
MYPY = False MYPY = False
if MYPY: if MYPY:
from cura.PrinterOutput.PrintJobOutputModel import PrintJobOutputModel from cura.PrinterOutput.Models.PrintJobOutputModel import PrintJobOutputModel
from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.PrinterOutputController import PrinterOutputController
@ -22,138 +22,140 @@ class PrinterOutputModel(QObject):
nameChanged = pyqtSignal() nameChanged = pyqtSignal()
headPositionChanged = pyqtSignal() headPositionChanged = pyqtSignal()
keyChanged = pyqtSignal() keyChanged = pyqtSignal()
printerTypeChanged = pyqtSignal() typeChanged = pyqtSignal()
buildplateChanged = pyqtSignal() buildplateChanged = pyqtSignal()
cameraChanged = pyqtSignal() cameraUrlChanged = pyqtSignal()
configurationChanged = pyqtSignal() configurationChanged = pyqtSignal()
canUpdateFirmwareChanged = pyqtSignal()
def __init__(self, output_controller: "PrinterOutputController", number_of_extruders: int = 1, parent=None, firmware_version = ""): def __init__(self, output_controller: "PrinterOutputController", number_of_extruders: int = 1, parent=None, firmware_version = "") -> None:
super().__init__(parent) super().__init__(parent)
self._bed_temperature = -1 # Use -1 for no heated bed. self._bed_temperature = -1 # type: float # Use -1 for no heated bed.
self._target_bed_temperature = 0 self._target_bed_temperature = 0 # type: float
self._name = "" self._name = ""
self._key = "" # Unique identifier self._key = "" # Unique identifier
self._controller = output_controller self._controller = output_controller
self._controller.canUpdateFirmwareChanged.connect(self._onControllerCanUpdateFirmwareChanged)
self._extruders = [ExtruderOutputModel(printer = self, position = i) for i in range(number_of_extruders)] self._extruders = [ExtruderOutputModel(printer = self, position = i) for i in range(number_of_extruders)]
self._printer_configuration = ConfigurationModel() # Indicates the current configuration setup in this printer self._printer_configuration = PrinterConfigurationModel() # Indicates the current configuration setup in this printer
self._head_position = Vector(0, 0, 0) self._head_position = Vector(0, 0, 0)
self._active_print_job = None # type: Optional[PrintJobOutputModel] self._active_print_job = None # type: Optional[PrintJobOutputModel]
self._firmware_version = firmware_version self._firmware_version = firmware_version
self._printer_state = "unknown" self._printer_state = "unknown"
self._is_preheating = False self._is_preheating = False
self._printer_type = "" self._printer_type = ""
self._buildplate_name = None self._buildplate = ""
# Update the printer configuration every time any of the extruders changes its configuration
for extruder in self._extruders:
extruder.extruderConfigurationChanged.connect(self._updateExtruderConfiguration)
self._camera = None self._printer_configuration.extruderConfigurations = [extruder.extruderConfiguration for extruder in
self._extruders]
self._camera_url = QUrl() # type: QUrl
@pyqtProperty(str, constant = True) @pyqtProperty(str, constant = True)
def firmwareVersion(self): def firmwareVersion(self) -> str:
return self._firmware_version return self._firmware_version
def setCamera(self, camera): def setCameraUrl(self, camera_url: "QUrl") -> None:
if self._camera is not camera: if self._camera_url != camera_url:
self._camera = camera self._camera_url = camera_url
self.cameraChanged.emit() self.cameraUrlChanged.emit()
def updateIsPreheating(self, pre_heating): @pyqtProperty(QUrl, fset = setCameraUrl, notify = cameraUrlChanged)
def cameraUrl(self) -> "QUrl":
return self._camera_url
def updateIsPreheating(self, pre_heating: bool) -> None:
if self._is_preheating != pre_heating: if self._is_preheating != pre_heating:
self._is_preheating = pre_heating self._is_preheating = pre_heating
self.isPreheatingChanged.emit() self.isPreheatingChanged.emit()
@pyqtProperty(bool, notify=isPreheatingChanged) @pyqtProperty(bool, notify=isPreheatingChanged)
def isPreheating(self): def isPreheating(self) -> bool:
return self._is_preheating return self._is_preheating
@pyqtProperty(QObject, notify=cameraChanged) @pyqtProperty(str, notify = typeChanged)
def camera(self): def type(self) -> str:
return self._camera
@pyqtProperty(str, notify = printerTypeChanged)
def type(self):
return self._printer_type return self._printer_type
def updateType(self, printer_type): def updateType(self, printer_type: str) -> None:
if self._printer_type != printer_type: if self._printer_type != printer_type:
self._printer_type = printer_type self._printer_type = printer_type
self._printer_configuration.printerType = self._printer_type self._printer_configuration.printerType = self._printer_type
self.printerTypeChanged.emit() self.typeChanged.emit()
self.configurationChanged.emit() self.configurationChanged.emit()
@pyqtProperty(str, notify = buildplateChanged) @pyqtProperty(str, notify = buildplateChanged)
def buildplate(self): def buildplate(self) -> str:
return self._buildplate_name return self._buildplate
def updateBuildplateName(self, buildplate_name): def updateBuildplate(self, buildplate: str) -> None:
if self._buildplate_name != buildplate_name: if self._buildplate != buildplate:
self._buildplate_name = buildplate_name self._buildplate = buildplate
self._printer_configuration.buildplateConfiguration = self._buildplate_name self._printer_configuration.buildplateConfiguration = self._buildplate
self.buildplateChanged.emit() self.buildplateChanged.emit()
self.configurationChanged.emit() self.configurationChanged.emit()
@pyqtProperty(str, notify=keyChanged) @pyqtProperty(str, notify=keyChanged)
def key(self): def key(self) -> str:
return self._key return self._key
def updateKey(self, key: str): def updateKey(self, key: str) -> None:
if self._key != key: if self._key != key:
self._key = key self._key = key
self.keyChanged.emit() self.keyChanged.emit()
@pyqtSlot() @pyqtSlot()
def homeHead(self): def homeHead(self) -> None:
self._controller.homeHead(self) self._controller.homeHead(self)
@pyqtSlot() @pyqtSlot()
def homeBed(self): def homeBed(self) -> None:
self._controller.homeBed(self) self._controller.homeBed(self)
@pyqtSlot(str) @pyqtSlot(str)
def sendRawCommand(self, command: str): def sendRawCommand(self, command: str) -> None:
self._controller.sendRawCommand(self, command) self._controller.sendRawCommand(self, command)
@pyqtProperty("QVariantList", constant = True) @pyqtProperty("QVariantList", constant = True)
def extruders(self): def extruders(self) -> List["ExtruderOutputModel"]:
return self._extruders return self._extruders
@pyqtProperty(QVariant, notify = headPositionChanged) @pyqtProperty(QVariant, notify = headPositionChanged)
def headPosition(self): def headPosition(self) -> Dict[str, float]:
return {"x": self._head_position.x, "y": self._head_position.y, "z": self.head_position_z} return {"x": self._head_position.x, "y": self._head_position.y, "z": self.head_position.z}
def updateHeadPosition(self, x, y, z): def updateHeadPosition(self, x: float, y: float, z: float) -> None:
if self._head_position.x != x or self._head_position.y != y or self._head_position.z != z: if self._head_position.x != x or self._head_position.y != y or self._head_position.z != z:
self._head_position = Vector(x, y, z) self._head_position = Vector(x, y, z)
self.headPositionChanged.emit() self.headPositionChanged.emit()
@pyqtProperty(float, float, float) @pyqtProperty(float, float, float)
@pyqtProperty(float, float, float, float) @pyqtProperty(float, float, float, float)
def setHeadPosition(self, x, y, z, speed = 3000): def setHeadPosition(self, x: float, y: float, z: float, speed: float = 3000) -> None:
self.updateHeadPosition(x, y, z) self.updateHeadPosition(x, y, z)
self._controller.setHeadPosition(self, x, y, z, speed) self._controller.setHeadPosition(self, x, y, z, speed)
@pyqtProperty(float) @pyqtProperty(float)
@pyqtProperty(float, float) @pyqtProperty(float, float)
def setHeadX(self, x, speed = 3000): def setHeadX(self, x: float, speed: float = 3000) -> None:
self.updateHeadPosition(x, self._head_position.y, self._head_position.z) self.updateHeadPosition(x, self._head_position.y, self._head_position.z)
self._controller.setHeadPosition(self, x, self._head_position.y, self._head_position.z, speed) self._controller.setHeadPosition(self, x, self._head_position.y, self._head_position.z, speed)
@pyqtProperty(float) @pyqtProperty(float)
@pyqtProperty(float, float) @pyqtProperty(float, float)
def setHeadY(self, y, speed = 3000): def setHeadY(self, y: float, speed: float = 3000) -> None:
self.updateHeadPosition(self._head_position.x, y, self._head_position.z) self.updateHeadPosition(self._head_position.x, y, self._head_position.z)
self._controller.setHeadPosition(self, self._head_position.x, y, self._head_position.z, speed) self._controller.setHeadPosition(self, self._head_position.x, y, self._head_position.z, speed)
@pyqtProperty(float) @pyqtProperty(float)
@pyqtProperty(float, float) @pyqtProperty(float, float)
def setHeadZ(self, z, speed = 3000): def setHeadZ(self, z: float, speed:float = 3000) -> None:
self.updateHeadPosition(self._head_position.x, self._head_position.y, z) self.updateHeadPosition(self._head_position.x, self._head_position.y, z)
self._controller.setHeadPosition(self, self._head_position.x, self._head_position.y, z, speed) self._controller.setHeadPosition(self, self._head_position.x, self._head_position.y, z, speed)
@pyqtSlot(float, float, float) @pyqtSlot(float, float, float)
@pyqtSlot(float, float, float, float) @pyqtSlot(float, float, float, float)
def moveHead(self, x = 0, y = 0, z = 0, speed = 3000): def moveHead(self, x: float = 0, y: float = 0, z: float = 0, speed: float = 3000) -> None:
self._controller.moveHead(self, x, y, z, speed) self._controller.moveHead(self, x, y, z, speed)
## Pre-heats the heated bed of the printer. ## Pre-heats the heated bed of the printer.
@ -162,47 +164,46 @@ class PrinterOutputModel(QObject):
# Celsius. # Celsius.
# \param duration How long the bed should stay warm, in seconds. # \param duration How long the bed should stay warm, in seconds.
@pyqtSlot(float, float) @pyqtSlot(float, float)
def preheatBed(self, temperature, duration): def preheatBed(self, temperature: float, duration: float) -> None:
self._controller.preheatBed(self, temperature, duration) self._controller.preheatBed(self, temperature, duration)
@pyqtSlot() @pyqtSlot()
def cancelPreheatBed(self): def cancelPreheatBed(self) -> None:
self._controller.cancelPreheatBed(self) self._controller.cancelPreheatBed(self)
def getController(self): def getController(self) -> "PrinterOutputController":
return self._controller return self._controller
@pyqtProperty(str, notify=nameChanged) @pyqtProperty(str, notify = nameChanged)
def name(self): def name(self) -> str:
return self._name return self._name
def setName(self, name): def setName(self, name: str) -> None:
self._setName(name)
self.updateName(name) self.updateName(name)
def updateName(self, name): def updateName(self, name: str) -> None:
if self._name != name: if self._name != name:
self._name = name self._name = name
self.nameChanged.emit() self.nameChanged.emit()
## Update the bed temperature. This only changes it locally. ## Update the bed temperature. This only changes it locally.
def updateBedTemperature(self, temperature): def updateBedTemperature(self, temperature: float) -> None:
if self._bed_temperature != temperature: if self._bed_temperature != temperature:
self._bed_temperature = temperature self._bed_temperature = temperature
self.bedTemperatureChanged.emit() self.bedTemperatureChanged.emit()
def updateTargetBedTemperature(self, temperature): def updateTargetBedTemperature(self, temperature: float) -> None:
if self._target_bed_temperature != temperature: if self._target_bed_temperature != temperature:
self._target_bed_temperature = temperature self._target_bed_temperature = temperature
self.targetBedTemperatureChanged.emit() self.targetBedTemperatureChanged.emit()
## Set the target bed temperature. This ensures that it's actually sent to the remote. ## Set the target bed temperature. This ensures that it's actually sent to the remote.
@pyqtSlot(int) @pyqtSlot(float)
def setTargetBedTemperature(self, temperature): def setTargetBedTemperature(self, temperature: float) -> None:
self._controller.setTargetBedTemperature(self, temperature) self._controller.setTargetBedTemperature(self, temperature)
self.updateTargetBedTemperature(temperature) self.updateTargetBedTemperature(temperature)
def updateActivePrintJob(self, print_job): def updateActivePrintJob(self, print_job: Optional["PrintJobOutputModel"]) -> None:
if self._active_print_job != print_job: if self._active_print_job != print_job:
old_print_job = self._active_print_job old_print_job = self._active_print_job
@ -214,76 +215,83 @@ class PrinterOutputModel(QObject):
old_print_job.updateAssignedPrinter(None) old_print_job.updateAssignedPrinter(None)
self.activePrintJobChanged.emit() self.activePrintJobChanged.emit()
def updateState(self, printer_state): def updateState(self, printer_state: str) -> None:
if self._printer_state != printer_state: if self._printer_state != printer_state:
self._printer_state = printer_state self._printer_state = printer_state
self.stateChanged.emit() self.stateChanged.emit()
@pyqtProperty(QObject, notify = activePrintJobChanged) @pyqtProperty(QObject, notify = activePrintJobChanged)
def activePrintJob(self): def activePrintJob(self) -> Optional["PrintJobOutputModel"]:
return self._active_print_job return self._active_print_job
@pyqtProperty(str, notify=stateChanged) @pyqtProperty(str, notify = stateChanged)
def state(self): def state(self) -> str:
return self._printer_state return self._printer_state
@pyqtProperty(int, notify = bedTemperatureChanged) @pyqtProperty(float, notify = bedTemperatureChanged)
def bedTemperature(self): def bedTemperature(self) -> float:
return self._bed_temperature return self._bed_temperature
@pyqtProperty(int, notify=targetBedTemperatureChanged) @pyqtProperty(float, notify = targetBedTemperatureChanged)
def targetBedTemperature(self): def targetBedTemperature(self) -> float:
return self._target_bed_temperature return self._target_bed_temperature
# Does the printer support pre-heating the bed at all # Does the printer support pre-heating the bed at all
@pyqtProperty(bool, constant=True) @pyqtProperty(bool, constant = True)
def canPreHeatBed(self): def canPreHeatBed(self) -> bool:
if self._controller: if self._controller:
return self._controller.can_pre_heat_bed return self._controller.can_pre_heat_bed
return False return False
# Does the printer support pre-heating the bed at all # Does the printer support pre-heating the bed at all
@pyqtProperty(bool, constant=True) @pyqtProperty(bool, constant = True)
def canPreHeatHotends(self): def canPreHeatHotends(self) -> bool:
if self._controller: if self._controller:
return self._controller.can_pre_heat_hotends return self._controller.can_pre_heat_hotends
return False return False
# Does the printer support sending raw G-code at all # Does the printer support sending raw G-code at all
@pyqtProperty(bool, constant=True) @pyqtProperty(bool, constant = True)
def canSendRawGcode(self): def canSendRawGcode(self) -> bool:
if self._controller: if self._controller:
return self._controller.can_send_raw_gcode return self._controller.can_send_raw_gcode
return False return False
# Does the printer support pause at all # Does the printer support pause at all
@pyqtProperty(bool, constant=True) @pyqtProperty(bool, constant = True)
def canPause(self): def canPause(self) -> bool:
if self._controller: if self._controller:
return self._controller.can_pause return self._controller.can_pause
return False return False
# Does the printer support abort at all # Does the printer support abort at all
@pyqtProperty(bool, constant=True) @pyqtProperty(bool, constant = True)
def canAbort(self): def canAbort(self) -> bool:
if self._controller: if self._controller:
return self._controller.can_abort return self._controller.can_abort
return False return False
# Does the printer support manual control at all # Does the printer support manual control at all
@pyqtProperty(bool, constant=True) @pyqtProperty(bool, constant = True)
def canControlManually(self): def canControlManually(self) -> bool:
if self._controller: if self._controller:
return self._controller.can_control_manually return self._controller.can_control_manually
return False return False
# Does the printer support upgrading firmware
@pyqtProperty(bool, notify = canUpdateFirmwareChanged)
def canUpdateFirmware(self) -> bool:
if self._controller:
return self._controller.can_update_firmware
return False
# Stub to connect UM.Signal to pyqtSignal
def _onControllerCanUpdateFirmwareChanged(self) -> None:
self.canUpdateFirmwareChanged.emit()
# Returns the configuration (material, variant and buildplate) of the current printer # Returns the configuration (material, variant and buildplate) of the current printer
@pyqtProperty(QObject, notify = configurationChanged) @pyqtProperty(QObject, notify = configurationChanged)
def printerConfiguration(self): def printerConfiguration(self) -> Optional[PrinterConfigurationModel]:
if self._printer_configuration.isValid(): if self._printer_configuration.isValid():
return self._printer_configuration return self._printer_configuration
return None return None
def _updateExtruderConfiguration(self):
self._printer_configuration.extruderConfigurations = [extruder.extruderConfiguration for extruder in self._extruders]
self.configurationChanged.emit()

View file

View file

@ -1,119 +0,0 @@
from UM.Logger import Logger
from PyQt5.QtCore import QUrl, pyqtProperty, pyqtSignal, QObject, pyqtSlot
from PyQt5.QtGui import QImage
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager
class NetworkCamera(QObject):
newImage = pyqtSignal()
def __init__(self, target = None, parent = None):
super().__init__(parent)
self._stream_buffer = b""
self._stream_buffer_start_index = -1
self._manager = None
self._image_request = None
self._image_reply = None
self._image = QImage()
self._image_id = 0
self._target = target
self._started = False
@pyqtSlot(str)
def setTarget(self, target):
restart_required = False
if self._started:
self.stop()
restart_required = True
self._target = target
if restart_required:
self.start()
@pyqtProperty(QUrl, notify=newImage)
def latestImage(self):
self._image_id += 1
# There is an image provider that is called "camera". In order to ensure that the image qml object, that
# requires a QUrl to function, updates correctly we add an increasing number. This causes to see the QUrl
# as new (instead of relying on cached version and thus forces an update.
temp = "image://camera/" + str(self._image_id)
return QUrl(temp, QUrl.TolerantMode)
@pyqtSlot()
def start(self):
# Ensure that previous requests (if any) are stopped.
self.stop()
if self._target is None:
Logger.log("w", "Unable to start camera stream without target!")
return
self._started = True
url = QUrl(self._target)
self._image_request = QNetworkRequest(url)
if self._manager is None:
self._manager = QNetworkAccessManager()
self._image_reply = self._manager.get(self._image_request)
self._image_reply.downloadProgress.connect(self._onStreamDownloadProgress)
@pyqtSlot()
def stop(self):
self._stream_buffer = b""
self._stream_buffer_start_index = -1
if self._image_reply:
try:
# disconnect the signal
try:
self._image_reply.downloadProgress.disconnect(self._onStreamDownloadProgress)
except Exception:
pass
# abort the request if it's not finished
if not self._image_reply.isFinished():
self._image_reply.close()
except Exception as e: # RuntimeError
pass # It can happen that the wrapped c++ object is already deleted.
self._image_reply = None
self._image_request = None
self._manager = None
self._started = False
def getImage(self):
return self._image
## Ensure that close gets called when object is destroyed
def __del__(self):
self.stop()
def _onStreamDownloadProgress(self, bytes_received, bytes_total):
# An MJPG stream is (for our purpose) a stream of concatenated JPG images.
# JPG images start with the marker 0xFFD8, and end with 0xFFD9
if self._image_reply is None:
return
self._stream_buffer += self._image_reply.readAll()
if len(self._stream_buffer) > 2000000: # No single camera frame should be 2 Mb or larger
Logger.log("w", "MJPEG buffer exceeds reasonable size. Restarting stream...")
self.stop() # resets stream buffer and start index
self.start()
return
if self._stream_buffer_start_index == -1:
self._stream_buffer_start_index = self._stream_buffer.indexOf(b'\xff\xd8')
stream_buffer_end_index = self._stream_buffer.lastIndexOf(b'\xff\xd9')
# If this happens to be more than a single frame, then so be it; the JPG decoder will
# ignore the extra data. We do it like this in order not to get a buildup of frames
if self._stream_buffer_start_index != -1 and stream_buffer_end_index != -1:
jpg_data = self._stream_buffer[self._stream_buffer_start_index:stream_buffer_end_index + 2]
self._stream_buffer = self._stream_buffer[stream_buffer_end_index + 2:]
self._stream_buffer_start_index = -1
self._image.loadFromData(jpg_data)
self.newImage.emit()

View file

@ -0,0 +1,153 @@
# Copyright (c) 2018 Aldo Hoeben / fieldOfView
# NetworkMJPGImage is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import QUrl, pyqtProperty, pyqtSignal, pyqtSlot, QRect, QByteArray
from PyQt5.QtGui import QImage, QPainter
from PyQt5.QtQuick import QQuickPaintedItem
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager
from UM.Logger import Logger
#
# A QQuickPaintedItem that progressively downloads a network mjpeg stream,
# picks it apart in individual jpeg frames, and paints it.
#
class NetworkMJPGImage(QQuickPaintedItem):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._stream_buffer = QByteArray()
self._stream_buffer_start_index = -1
self._network_manager = None # type: QNetworkAccessManager
self._image_request = None # type: QNetworkRequest
self._image_reply = None # type: QNetworkReply
self._image = QImage()
self._image_rect = QRect()
self._source_url = QUrl()
self._started = False
self._mirror = False
self.setAntialiasing(True)
## Ensure that close gets called when object is destroyed
def __del__(self) -> None:
self.stop()
def paint(self, painter: "QPainter") -> None:
if self._mirror:
painter.drawImage(self.contentsBoundingRect(), self._image.mirrored())
return
painter.drawImage(self.contentsBoundingRect(), self._image)
def setSourceURL(self, source_url: "QUrl") -> None:
self._source_url = source_url
self.sourceURLChanged.emit()
if self._started:
self.start()
def getSourceURL(self) -> "QUrl":
return self._source_url
sourceURLChanged = pyqtSignal()
source = pyqtProperty(QUrl, fget = getSourceURL, fset = setSourceURL, notify = sourceURLChanged)
def setMirror(self, mirror: bool) -> None:
if mirror == self._mirror:
return
self._mirror = mirror
self.mirrorChanged.emit()
self.update()
def getMirror(self) -> bool:
return self._mirror
mirrorChanged = pyqtSignal()
mirror = pyqtProperty(bool, fget = getMirror, fset = setMirror, notify = mirrorChanged)
imageSizeChanged = pyqtSignal()
@pyqtProperty(int, notify = imageSizeChanged)
def imageWidth(self) -> int:
return self._image.width()
@pyqtProperty(int, notify = imageSizeChanged)
def imageHeight(self) -> int:
return self._image.height()
@pyqtSlot()
def start(self) -> None:
self.stop() # Ensure that previous requests (if any) are stopped.
if not self._source_url:
Logger.log("w", "Unable to start camera stream without target!")
return
self._started = True
self._image_request = QNetworkRequest(self._source_url)
if self._network_manager is None:
self._network_manager = QNetworkAccessManager()
self._image_reply = self._network_manager.get(self._image_request)
self._image_reply.downloadProgress.connect(self._onStreamDownloadProgress)
@pyqtSlot()
def stop(self) -> None:
self._stream_buffer = QByteArray()
self._stream_buffer_start_index = -1
if self._image_reply:
try:
try:
self._image_reply.downloadProgress.disconnect(self._onStreamDownloadProgress)
except Exception:
pass
if not self._image_reply.isFinished():
self._image_reply.close()
except Exception as e: # RuntimeError
pass # It can happen that the wrapped c++ object is already deleted.
self._image_reply = None
self._image_request = None
self._network_manager = None
self._started = False
def _onStreamDownloadProgress(self, bytes_received: int, bytes_total: int) -> None:
# An MJPG stream is (for our purpose) a stream of concatenated JPG images.
# JPG images start with the marker 0xFFD8, and end with 0xFFD9
if self._image_reply is None:
return
self._stream_buffer += self._image_reply.readAll()
if len(self._stream_buffer) > 2000000: # No single camera frame should be 2 Mb or larger
Logger.log("w", "MJPEG buffer exceeds reasonable size. Restarting stream...")
self.stop() # resets stream buffer and start index
self.start()
return
if self._stream_buffer_start_index == -1:
self._stream_buffer_start_index = self._stream_buffer.indexOf(b'\xff\xd8')
stream_buffer_end_index = self._stream_buffer.lastIndexOf(b'\xff\xd9')
# If this happens to be more than a single frame, then so be it; the JPG decoder will
# ignore the extra data. We do it like this in order not to get a buildup of frames
if self._stream_buffer_start_index != -1 and stream_buffer_end_index != -1:
jpg_data = self._stream_buffer[self._stream_buffer_start_index:stream_buffer_end_index + 2]
self._stream_buffer = self._stream_buffer[stream_buffer_end_index + 2:]
self._stream_buffer_start_index = -1
self._image.loadFromData(jpg_data)
if self._image.rect() != self._image_rect:
self.imageSizeChanged.emit()
self.update()

View file

@ -1,22 +1,26 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from UM.Application import Application from UM.FileHandler.FileHandler import FileHandler #For typing.
from UM.Logger import Logger from UM.Logger import Logger
from UM.Scene.SceneNode import SceneNode #For typing.
from cura.API import Account
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice, ConnectionState, ConnectionType
from PyQt5.QtNetwork import QHttpMultiPart, QHttpPart, QNetworkRequest, QNetworkAccessManager, QNetworkReply from PyQt5.QtNetwork import QHttpMultiPart, QHttpPart, QNetworkRequest, QNetworkAccessManager, QNetworkReply, QAuthenticator
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, pyqtSignal, QUrl, QCoreApplication from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QCoreApplication
from time import time from time import time
from typing import Callable, Any, Optional, Dict, Tuple from typing import Callable, Dict, List, Optional, Union
from enum import IntEnum from enum import IntEnum
from typing import List
import os # To get the username import os # To get the username
import gzip import gzip
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
class AuthState(IntEnum): class AuthState(IntEnum):
NotAuthenticated = 1 NotAuthenticated = 1
AuthenticationRequested = 2 AuthenticationRequested = 2
@ -28,20 +32,21 @@ class AuthState(IntEnum):
class NetworkedPrinterOutputDevice(PrinterOutputDevice): class NetworkedPrinterOutputDevice(PrinterOutputDevice):
authenticationStateChanged = pyqtSignal() authenticationStateChanged = pyqtSignal()
def __init__(self, device_id, address: str, properties, parent = None) -> None: def __init__(self, device_id, address: str, properties: Dict[bytes, bytes], connection_type: ConnectionType = ConnectionType.NetworkConnection, parent: QObject = None) -> None:
super().__init__(device_id = device_id, parent = parent) super().__init__(device_id = device_id, connection_type = connection_type, parent = parent)
self._manager = None # type: QNetworkAccessManager self._manager = None # type: Optional[QNetworkAccessManager]
self._last_manager_create_time = None # type: float self._last_manager_create_time = None # type: Optional[float]
self._recreate_network_manager_time = 30 self._recreate_network_manager_time = 30
self._timeout_time = 10 # After how many seconds of no response should a timeout occur? self._timeout_time = 10 # After how many seconds of no response should a timeout occur?
self._last_response_time = None # type: float self._last_response_time = None # type: Optional[float]
self._last_request_time = None # type: float self._last_request_time = None # type: Optional[float]
self._api_prefix = "" self._api_prefix = ""
self._address = address self._address = address
self._properties = properties self._properties = properties
self._user_agent = "%s/%s " % (Application.getInstance().getApplicationName(), Application.getInstance().getVersion()) self._user_agent = "%s/%s " % (CuraApplication.getInstance().getApplicationName(),
CuraApplication.getInstance().getVersion())
self._onFinishedCallbacks = {} # type: Dict[str, Callable[[QNetworkReply], None]] self._onFinishedCallbacks = {} # type: Dict[str, Callable[[QNetworkReply], None]]
self._authentication_state = AuthState.NotAuthenticated self._authentication_state = AuthState.NotAuthenticated
@ -53,31 +58,19 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
self._sending_gcode = False self._sending_gcode = False
self._compressing_gcode = False self._compressing_gcode = False
self._gcode = [] # type: List[str] self._gcode = [] # type: List[str]
self._connection_state_before_timeout = None # type: Optional[ConnectionState] self._connection_state_before_timeout = None # type: Optional[ConnectionState]
printer_type = self._properties.get(b"machine", b"").decode("utf-8") def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False,
printer_type_identifiers = { file_handler: Optional[FileHandler] = None, **kwargs: str) -> None:
"9066": "ultimaker3",
"9511": "ultimaker3_extended",
"9051": "ultimaker_s5"
}
self._printer_type = "Unknown"
for key, value in printer_type_identifiers.items():
if printer_type.startswith(key):
self._printer_type = value
break
def requestWrite(self, nodes, file_name=None, filter_by_machine=False, file_handler=None, **kwargs) -> None:
raise NotImplementedError("requestWrite needs to be implemented") raise NotImplementedError("requestWrite needs to be implemented")
def setAuthenticationState(self, authentication_state) -> None: def setAuthenticationState(self, authentication_state: AuthState) -> None:
if self._authentication_state != authentication_state: if self._authentication_state != authentication_state:
self._authentication_state = authentication_state self._authentication_state = authentication_state
self.authenticationStateChanged.emit() self.authenticationStateChanged.emit()
@pyqtProperty(int, notify=authenticationStateChanged) @pyqtProperty(int, notify = authenticationStateChanged)
def authenticationState(self) -> int: def authenticationState(self) -> AuthState:
return self._authentication_state return self._authentication_state
def _compressDataAndNotifyQt(self, data_to_append: str) -> bytes: def _compressDataAndNotifyQt(self, data_to_append: str) -> bytes:
@ -122,7 +115,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
self._compressing_gcode = False self._compressing_gcode = False
return b"".join(file_data_bytes_list) return b"".join(file_data_bytes_list)
def _update(self) -> bool: def _update(self) -> None:
if self._last_response_time: if self._last_response_time:
time_since_last_response = time() - self._last_response_time time_since_last_response = time() - self._last_response_time
else: else:
@ -138,31 +131,34 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
if self._connection_state_before_timeout is None: if self._connection_state_before_timeout is None:
self._connection_state_before_timeout = self._connection_state self._connection_state_before_timeout = self._connection_state
self.setConnectionState(ConnectionState.closed) self.setConnectionState(ConnectionState.Closed)
# We need to check if the manager needs to be re-created. If we don't, we get some issues when OSX goes to # We need to check if the manager needs to be re-created. If we don't, we get some issues when OSX goes to
# sleep. # sleep.
if time_since_last_response > self._recreate_network_manager_time: if time_since_last_response > self._recreate_network_manager_time:
if self._last_manager_create_time is None: if self._last_manager_create_time is None or time() - self._last_manager_create_time > self._recreate_network_manager_time:
self._createNetworkManager() self._createNetworkManager()
if time() - self._last_manager_create_time > self._recreate_network_manager_time: assert(self._manager is not None)
self._createNetworkManager() elif self._connection_state == ConnectionState.Closed:
elif self._connection_state == ConnectionState.closed:
# Go out of timeout. # Go out of timeout.
if self._connection_state_before_timeout is not None: # sanity check, but it should never be None here
self.setConnectionState(self._connection_state_before_timeout) self.setConnectionState(self._connection_state_before_timeout)
self._connection_state_before_timeout = None self._connection_state_before_timeout = None
return True def _createEmptyRequest(self, target: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
def _createEmptyRequest(self, target, content_type: Optional[str] = "application/json") -> QNetworkRequest:
url = QUrl("http://" + self._address + self._api_prefix + target) url = QUrl("http://" + self._address + self._api_prefix + target)
request = QNetworkRequest(url) request = QNetworkRequest(url)
if content_type is not None: if content_type is not None:
request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json") request.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent)
return request return request
def _createFormPart(self, content_header, data, content_type = 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.
def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
return self._createFormPart(content_header, data, content_type)
def _createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
part = QHttpPart() part = QHttpPart()
if not content_header.startswith("form-data;"): if not content_header.startswith("form-data;"):
@ -175,9 +171,15 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
part.setBody(data) part.setBody(data)
return part return part
## Convenience function to get the username from the OS. ## Convenience function to get the username, either from the cloud or from the OS.
# The code was copied from the getpass module, as we try to use as little dependencies as possible.
def _getUserName(self) -> str: def _getUserName(self) -> str:
# check first if we are logged in with the Ultimaker Account
account = CuraApplication.getInstance().getCuraAPI().account # type: Account
if account and account.isLoggedIn:
return account.userName
# Otherwise get the username from the US
# The code below was copied from the getpass module, as we try to use as little dependencies as possible.
for name in ("LOGNAME", "USER", "LNAME", "USERNAME"): for name in ("LOGNAME", "USER", "LNAME", "USERNAME"):
user = os.environ.get(name) user = os.environ.get(name)
if user: if user:
@ -188,35 +190,95 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
if reply in self._kept_alive_multiparts: if reply in self._kept_alive_multiparts:
del self._kept_alive_multiparts[reply] del self._kept_alive_multiparts[reply]
def put(self, target: str, data: str, onFinished: Optional[Callable[[Any, QNetworkReply], None]]) -> None: def _validateManager(self) -> None:
if self._manager is None: if self._manager is None:
self._createNetworkManager() self._createNetworkManager()
request = self._createEmptyRequest(target) assert (self._manager is not None)
self._last_request_time = time()
reply = self._manager.put(request, data.encode())
self._registerOnFinishedCallback(reply, onFinished)
def get(self, target: str, onFinished: Optional[Callable[[Any, QNetworkReply], None]]) -> None: ## Sends a put request to the given path.
if self._manager is None: # \param url: The path after the API prefix.
self._createNetworkManager() # \param data: The data to be sent in the body
request = self._createEmptyRequest(target) # \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:
self._validateManager()
request = self._createEmptyRequest(url, content_type = content_type)
self._last_request_time = time() self._last_request_time = time()
if not self._manager:
Logger.log("e", "No network manager was created to execute the PUT call with.")
return
body = data if isinstance(data, bytes) else data.encode() # type: bytes
reply = self._manager.put(request, body)
self._registerOnFinishedCallback(reply, on_finished)
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:
self._validateManager()
request = self._createEmptyRequest(url)
self._last_request_time = time()
if not self._manager:
Logger.log("e", "No network manager was created to execute the DELETE call with.")
return
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:
self._validateManager()
request = self._createEmptyRequest(url)
self._last_request_time = time()
if not self._manager:
Logger.log("e", "No network manager was created to execute the GET call with.")
return
reply = self._manager.get(request) reply = self._manager.get(request)
self._registerOnFinishedCallback(reply, onFinished) self._registerOnFinishedCallback(reply, on_finished)
def post(self, target: str, data: str, onFinished: Optional[Callable[[Any, QNetworkReply], None]], onProgress: Callable = None) -> None: ## Sends a post request to the given path.
if self._manager is None: # \param url: The path after the API prefix.
self._createNetworkManager() # \param data: The data to be sent in the body
request = self._createEmptyRequest(target) # \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:
self._validateManager()
request = self._createEmptyRequest(url)
self._last_request_time = time() self._last_request_time = time()
reply = self._manager.post(request, data)
if onProgress is not None:
reply.uploadProgress.connect(onProgress)
self._registerOnFinishedCallback(reply, onFinished)
def postFormWithParts(self, target:str, parts: List[QHttpPart], onFinished: Optional[Callable[[Any, QNetworkReply], None]], onProgress: Callable = None) -> None: if not self._manager:
if self._manager is None: Logger.log("e", "Could not find manager.")
self._createNetworkManager() return
body = data if isinstance(data, bytes) else data.encode() # type: bytes
reply = self._manager.post(request, body)
if on_progress is not None:
reply.uploadProgress.connect(on_progress)
self._registerOnFinishedCallback(reply, on_finished)
def postFormWithParts(self, target: str, parts: List[QHttpPart],
on_finished: Optional[Callable[[QNetworkReply], None]],
on_progress: Optional[Callable[[int, int], None]] = None) -> QNetworkReply:
self._validateManager()
request = self._createEmptyRequest(target, content_type=None) request = self._createEmptyRequest(target, content_type=None)
multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType) multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType)
for part in parts: for part in parts:
@ -224,45 +286,63 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
self._last_request_time = time() self._last_request_time = time()
if self._manager is not None:
reply = self._manager.post(request, multi_post_part) reply = self._manager.post(request, multi_post_part)
self._kept_alive_multiparts[reply] = multi_post_part self._kept_alive_multiparts[reply] = multi_post_part
if onProgress is not None: if on_progress is not None:
reply.uploadProgress.connect(onProgress) reply.uploadProgress.connect(on_progress)
self._registerOnFinishedCallback(reply, onFinished) self._registerOnFinishedCallback(reply, on_finished)
return reply return reply
else:
Logger.log("e", "Could not find manager.")
def postForm(self, target: str, header_data: str, body_data: bytes, onFinished: Optional[Callable[[Any, QNetworkReply], None]], onProgress: Callable = None) -> None: def postForm(self, target: str, header_data: str, body_data: bytes, on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> None:
post_part = QHttpPart() post_part = QHttpPart()
post_part.setHeader(QNetworkRequest.ContentDispositionHeader, header_data) post_part.setHeader(QNetworkRequest.ContentDispositionHeader, header_data)
post_part.setBody(body_data) post_part.setBody(body_data)
self.postFormWithParts(target, [post_part], onFinished, onProgress) self.postFormWithParts(target, [post_part], on_finished, on_progress)
def _onAuthenticationRequired(self, reply, authenticator) -> None: def _onAuthenticationRequired(self, reply: QNetworkReply, authenticator: QAuthenticator) -> None:
Logger.log("w", "Request to {url} required authentication, which was not implemented".format(url = reply.url().toString())) Logger.log("w", "Request to {url} required authentication, which was not implemented".format(url = reply.url().toString()))
def _createNetworkManager(self) -> None: def _createNetworkManager(self) -> None:
Logger.log("d", "Creating network manager") Logger.log("d", "Creating network manager")
if self._manager: if self._manager:
self._manager.finished.disconnect(self.__handleOnFinished) self._manager.finished.disconnect(self._handleOnFinished)
self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired) self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired)
self._manager = QNetworkAccessManager() self._manager = QNetworkAccessManager()
self._manager.finished.connect(self.__handleOnFinished) self._manager.finished.connect(self._handleOnFinished)
self._last_manager_create_time = time() self._last_manager_create_time = time()
self._manager.authenticationRequired.connect(self._onAuthenticationRequired) self._manager.authenticationRequired.connect(self._onAuthenticationRequired)
machine_manager = CuraApplication.getInstance().getMachineManager() if self._properties.get(b"temporary", b"false") != b"true":
machine_manager.checkCorrectGroupName(self.getId(), self.name) self._checkCorrectGroupName(self.getId(), self.name)
def _registerOnFinishedCallback(self, reply: QNetworkReply, onFinished: Optional[Callable[[Any, QNetworkReply], None]]) -> None: def _registerOnFinishedCallback(self, reply: QNetworkReply, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
if onFinished is not None: if on_finished is not None:
self._onFinishedCallbacks[reply.url().toString() + str(reply.operation())] = onFinished self._onFinishedCallbacks[reply.url().toString() + str(reply.operation())] = on_finished
def __handleOnFinished(self, reply: QNetworkReply) -> 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.
def _checkCorrectGroupName(self, device_id: str, group_name: str) -> None:
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:
# Check if the group_name is correct. If not, update all the containers connected to the same printer
if CuraApplication.getInstance().getMachineManager().activeMachineNetworkGroupName != group_name:
metadata_filter = {"um_network_key": active_machine_network_name}
containers = CuraContainerRegistry.getInstance().findContainerStacks(type="machine",
**metadata_filter)
for container in containers:
container.setMetaDataEntry("group_name", group_name)
def _handleOnFinished(self, reply: QNetworkReply) -> None:
# Due to garbage collection, we need to cache certain bits of post operations. # Due to garbage collection, we need to cache certain bits of post operations.
# As we don't want to keep them around forever, delete them if we get a reply. # As we don't want to keep them around forever, delete them if we get a reply.
if reply.operation() == QNetworkAccessManager.PostOperation: if reply.operation() == QNetworkAccessManager.PostOperation:
@ -274,8 +354,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
self._last_response_time = time() self._last_response_time = time()
if self._connection_state == ConnectionState.connecting: if self._connection_state == ConnectionState.Connecting:
self.setConnectionState(ConnectionState.connected) self.setConnectionState(ConnectionState.Connected)
callback_key = reply.url().toString() + str(reply.operation()) callback_key = reply.url().toString() + str(reply.operation())
try: try:
@ -297,30 +377,30 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
## Get the unique key of this machine ## Get the unique key of this machine
# \return key String containing the key of the machine. # \return key String containing the key of the machine.
@pyqtProperty(str, constant=True) @pyqtProperty(str, constant = True)
def key(self) -> str: def key(self) -> str:
return self._id return self._id
## The IP address of the printer. ## The IP address of the printer.
@pyqtProperty(str, constant=True) @pyqtProperty(str, constant = True)
def address(self) -> str: def address(self) -> str:
return self._properties.get(b"address", b"").decode("utf-8") return self._properties.get(b"address", b"").decode("utf-8")
## Name of the printer (as returned from the ZeroConf properties) ## Name of the printer (as returned from the ZeroConf properties)
@pyqtProperty(str, constant=True) @pyqtProperty(str, constant = True)
def name(self) -> str: def name(self) -> str:
return self._properties.get(b"name", b"").decode("utf-8") return self._properties.get(b"name", b"").decode("utf-8")
## Firmware version (as returned from the ZeroConf properties) ## Firmware version (as returned from the ZeroConf properties)
@pyqtProperty(str, constant=True) @pyqtProperty(str, constant = True)
def firmwareVersion(self) -> str: def firmwareVersion(self) -> str:
return self._properties.get(b"firmware_version", b"").decode("utf-8") return self._properties.get(b"firmware_version", b"").decode("utf-8")
@pyqtProperty(str, constant=True) @pyqtProperty(str, constant = True)
def printerType(self) -> str: def printerType(self) -> str:
return self._printer_type return self._properties.get(b"printer_type", b"Unknown").decode("utf-8")
## IPadress of this printer ## IP adress of this printer
@pyqtProperty(str, constant=True) @pyqtProperty(str, constant = True)
def ipAddress(self) -> str: def ipAddress(self) -> str:
return self._address return self._address

View file

@ -1,101 +0,0 @@
# Copyright (c) 2017 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, pyqtSlot
from typing import Optional
MYPY = False
if MYPY:
from cura.PrinterOutput.PrinterOutputController import PrinterOutputController
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
class PrintJobOutputModel(QObject):
stateChanged = pyqtSignal()
timeTotalChanged = pyqtSignal()
timeElapsedChanged = pyqtSignal()
nameChanged = pyqtSignal()
keyChanged = pyqtSignal()
assignedPrinterChanged = pyqtSignal()
ownerChanged = pyqtSignal()
def __init__(self, output_controller: "PrinterOutputController", key: str = "", name: str = "", parent=None):
super().__init__(parent)
self._output_controller = output_controller
self._state = ""
self._time_total = 0
self._time_elapsed = 0
self._name = name # Human readable name
self._key = key # Unique identifier
self._assigned_printer = None # type: Optional[PrinterOutputModel]
self._owner = "" # Who started/owns the print job?
@pyqtProperty(str, notify=ownerChanged)
def owner(self):
return self._owner
def updateOwner(self, owner):
if self._owner != owner:
self._owner = owner
self.ownerChanged.emit()
@pyqtProperty(QObject, notify=assignedPrinterChanged)
def assignedPrinter(self):
return self._assigned_printer
def updateAssignedPrinter(self, assigned_printer: "PrinterOutputModel"):
if self._assigned_printer != assigned_printer:
old_printer = self._assigned_printer
self._assigned_printer = assigned_printer
if old_printer is not None:
# If the previously assigned printer is set, this job is moved away from it.
old_printer.updateActivePrintJob(None)
self.assignedPrinterChanged.emit()
@pyqtProperty(str, notify=keyChanged)
def key(self):
return self._key
def updateKey(self, key: str):
if self._key != key:
self._key = key
self.keyChanged.emit()
@pyqtProperty(str, notify = nameChanged)
def name(self):
return self._name
def updateName(self, name: str):
if self._name != name:
self._name = name
self.nameChanged.emit()
@pyqtProperty(int, notify = timeTotalChanged)
def timeTotal(self):
return self._time_total
@pyqtProperty(int, notify = timeElapsedChanged)
def timeElapsed(self):
return self._time_elapsed
@pyqtProperty(str, notify=stateChanged)
def state(self):
return self._state
def updateTimeTotal(self, new_time_total):
if self._time_total != new_time_total:
self._time_total = new_time_total
self.timeTotalChanged.emit()
def updateTimeElapsed(self, new_time_elapsed):
if self._time_elapsed != new_time_elapsed:
self._time_elapsed = new_time_elapsed
self.timeElapsedChanged.emit()
def updateState(self, new_state):
if self._state != new_state:
self._state = new_state
self.stateChanged.emit()
@pyqtSlot(str)
def setState(self, state):
self._output_controller.setJobState(self, state)

View file

@ -1,57 +1,66 @@
# Copyright (c) 2017 Ultimaker B.V. # Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from UM.Logger import Logger from UM.Logger import Logger
from UM.Signal import Signal
MYPY = False MYPY = False
if MYPY: if MYPY:
from cura.PrinterOutput.PrintJobOutputModel import PrintJobOutputModel from .Models.PrintJobOutputModel import PrintJobOutputModel
from cura.PrinterOutput.ExtruderOutputModel import ExtruderOutputModel from .Models.ExtruderOutputModel import ExtruderOutputModel
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from .Models.PrinterOutputModel import PrinterOutputModel
from .PrinterOutputDevice import PrinterOutputDevice
class PrinterOutputController: class PrinterOutputController:
def __init__(self, output_device): def __init__(self, output_device: "PrinterOutputDevice") -> None:
self.can_pause = True self.can_pause = True
self.can_abort = True self.can_abort = True
self.can_pre_heat_bed = True self.can_pre_heat_bed = True
self.can_pre_heat_hotends = True self.can_pre_heat_hotends = True
self.can_send_raw_gcode = True self.can_send_raw_gcode = True
self.can_control_manually = True self.can_control_manually = True
self.can_update_firmware = False
self._output_device = output_device self._output_device = output_device
def setTargetHotendTemperature(self, printer: "PrinterOutputModel", extruder: "ExtruderOutputModel", temperature: int): def setTargetHotendTemperature(self, printer: "PrinterOutputModel", position: int, temperature: float) -> None:
Logger.log("w", "Set target hotend temperature not implemented in controller") Logger.log("w", "Set target hotend temperature not implemented in controller")
def setTargetBedTemperature(self, printer: "PrinterOutputModel", temperature: int): def setTargetBedTemperature(self, printer: "PrinterOutputModel", temperature: float) -> None:
Logger.log("w", "Set target bed temperature not implemented in controller") Logger.log("w", "Set target bed temperature not implemented in controller")
def setJobState(self, job: "PrintJobOutputModel", state: str): def setJobState(self, job: "PrintJobOutputModel", state: str) -> None:
Logger.log("w", "Set job state not implemented in controller") Logger.log("w", "Set job state not implemented in controller")
def cancelPreheatBed(self, printer: "PrinterOutputModel"): def cancelPreheatBed(self, printer: "PrinterOutputModel") -> None:
Logger.log("w", "Cancel preheat bed not implemented in controller") Logger.log("w", "Cancel preheat bed not implemented in controller")
def preheatBed(self, printer: "PrinterOutputModel", temperature, duration): def preheatBed(self, printer: "PrinterOutputModel", temperature, duration) -> None:
Logger.log("w", "Preheat bed not implemented in controller") Logger.log("w", "Preheat bed not implemented in controller")
def cancelPreheatHotend(self, extruder: "ExtruderOutputModel"): def cancelPreheatHotend(self, extruder: "ExtruderOutputModel") -> None:
Logger.log("w", "Cancel preheat hotend not implemented in controller") Logger.log("w", "Cancel preheat hotend not implemented in controller")
def preheatHotend(self, extruder: "ExtruderOutputModel", temperature, duration): def preheatHotend(self, extruder: "ExtruderOutputModel", temperature, duration) -> None:
Logger.log("w", "Preheat hotend not implemented in controller") Logger.log("w", "Preheat hotend not implemented in controller")
def setHeadPosition(self, printer: "PrinterOutputModel", x, y, z, speed): def setHeadPosition(self, printer: "PrinterOutputModel", x, y, z, speed) -> None:
Logger.log("w", "Set head position not implemented in controller") Logger.log("w", "Set head position not implemented in controller")
def moveHead(self, printer: "PrinterOutputModel", x, y, z, speed): def moveHead(self, printer: "PrinterOutputModel", x, y, z, speed) -> None:
Logger.log("w", "Move head not implemented in controller") Logger.log("w", "Move head not implemented in controller")
def homeBed(self, printer: "PrinterOutputModel"): def homeBed(self, printer: "PrinterOutputModel") -> None:
Logger.log("w", "Home bed not implemented in controller") Logger.log("w", "Home bed not implemented in controller")
def homeHead(self, printer: "PrinterOutputModel"): def homeHead(self, printer: "PrinterOutputModel") -> None:
Logger.log("w", "Home head not implemented in controller") Logger.log("w", "Home head not implemented in controller")
def sendRawCommand(self, printer: "PrinterOutputModel", command: str): def sendRawCommand(self, printer: "PrinterOutputModel", command: str) -> None:
Logger.log("w", "Custom command not implemented in controller") Logger.log("w", "Custom command not implemented in controller")
canUpdateFirmwareChanged = Signal()
def setCanUpdateFirmware(self, can_update_firmware: bool) -> None:
if can_update_firmware != self.can_update_firmware:
self.can_update_firmware = can_update_firmware
self.canUpdateFirmwareChanged.emit()

View file

@ -1,25 +1,46 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from UM.Decorators import deprecated from enum import IntEnum
from UM.i18n import i18nCatalog from typing import Callable, List, Optional, Union
from UM.OutputDevice.OutputDevice import OutputDevice
from PyQt5.QtCore import pyqtProperty, QObject, QTimer, pyqtSignal, QVariant from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, QTimer, QUrl
from PyQt5.QtWidgets import QMessageBox from PyQt5.QtWidgets import QMessageBox
from UM.Logger import Logger from UM.Logger import Logger
from UM.Signal import signalemitter from UM.Signal import signalemitter
from UM.Application import Application from UM.Qt.QtApplication import QtApplication
from UM.FlameProfiler import pyqtSlot
from enum import IntEnum # For the connection state tracking. from UM.Decorators import deprecated
from typing import List, Optional from UM.i18n import i18nCatalog
from UM.OutputDevice.OutputDevice import OutputDevice
MYPY = False MYPY = False
if MYPY: if MYPY:
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from UM.FileHandler.FileHandler import FileHandler
from cura.PrinterOutput.ConfigurationModel import ConfigurationModel from UM.Scene.SceneNode import SceneNode
from .Models.PrinterOutputModel import PrinterOutputModel
from .Models.PrinterConfigurationModel import PrinterConfigurationModel
from .FirmwareUpdater import FirmwareUpdater
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")
## The current processing state of the backend.
class ConnectionState(IntEnum):
Closed = 0
Connecting = 1
Connected = 2
Busy = 3
Error = 4
class ConnectionType(IntEnum):
NotConnected = 0
UsbConnection = 1
NetworkConnection = 2
CloudConnection = 3
## Printer output device adds extra interface options on top of output device. ## Printer output device adds extra interface options on top of output device.
# #
# The assumption is made the printer is a FDM printer. # The assumption is made the printer is a FDM printer.
@ -31,6 +52,7 @@ i18n_catalog = i18nCatalog("cura")
# For all other uses it should be used in the same way as a "regular" OutputDevice. # For all other uses it should be used in the same way as a "regular" OutputDevice.
@signalemitter @signalemitter
class PrinterOutputDevice(QObject, OutputDevice): class PrinterOutputDevice(QObject, OutputDevice):
printersChanged = pyqtSignal() printersChanged = pyqtSignal()
connectionStateChanged = pyqtSignal(str) connectionStateChanged = pyqtSignal(str)
acceptsCommandsChanged = pyqtSignal() acceptsCommandsChanged = pyqtSignal()
@ -47,38 +69,39 @@ class PrinterOutputDevice(QObject, OutputDevice):
# Signal to indicate that the configuration of one of the printers has changed. # Signal to indicate that the configuration of one of the printers has changed.
uniqueConfigurationsChanged = pyqtSignal() uniqueConfigurationsChanged = pyqtSignal()
def __init__(self, device_id, parent = None): def __init__(self, device_id: str, connection_type: "ConnectionType" = ConnectionType.NotConnected, parent: QObject = None) -> None:
super().__init__(device_id = device_id, parent = parent) super().__init__(device_id = device_id, parent = parent) # type: ignore # MyPy complains with the multiple inheritance
self._printers = [] # type: List[PrinterOutputModel] self._printers = [] # type: List[PrinterOutputModel]
self._unique_configurations = [] # type: List[ConfigurationModel] self._unique_configurations = [] # type: List[PrinterConfigurationModel]
self._monitor_view_qml_path = "" self._monitor_view_qml_path = "" # type: str
self._monitor_component = None self._monitor_component = None # type: Optional[QObject]
self._monitor_item = None self._monitor_item = None # type: Optional[QObject]
self._control_view_qml_path = "" self._control_view_qml_path = "" # type: str
self._control_component = None self._control_component = None # type: Optional[QObject]
self._control_item = None self._control_item = None # type: Optional[QObject]
self._qml_context = None self._accepts_commands = False # type: bool
self._accepts_commands = False
self._update_timer = QTimer() self._update_timer = QTimer() # type: QTimer
self._update_timer.setInterval(2000) # TODO; Add preference for update interval self._update_timer.setInterval(2000) # TODO; Add preference for update interval
self._update_timer.setSingleShot(False) self._update_timer.setSingleShot(False)
self._update_timer.timeout.connect(self._update) self._update_timer.timeout.connect(self._update)
self._connection_state = ConnectionState.closed self._connection_state = ConnectionState.Closed # type: ConnectionState
self._connection_type = connection_type # type: ConnectionType
self._firmware_name = None self._firmware_updater = None # type: Optional[FirmwareUpdater]
self._address = "" self._firmware_name = None # type: Optional[str]
self._connection_text = "" self._address = "" # type: str
self._connection_text = "" # type: str
self.printersChanged.connect(self._onPrintersChanged) self.printersChanged.connect(self._onPrintersChanged)
Application.getInstance().getOutputDeviceManager().outputDevicesChanged.connect(self._updateUniqueConfigurations) QtApplication.getInstance().getOutputDeviceManager().outputDevicesChanged.connect(self._updateUniqueConfigurations)
@pyqtProperty(str, notify = connectionTextChanged) @pyqtProperty(str, notify = connectionTextChanged)
def address(self): def address(self) -> str:
return self._address return self._address
def setConnectionText(self, connection_text): def setConnectionText(self, connection_text):
@ -87,36 +110,41 @@ class PrinterOutputDevice(QObject, OutputDevice):
self.connectionTextChanged.emit() self.connectionTextChanged.emit()
@pyqtProperty(str, constant=True) @pyqtProperty(str, constant=True)
def connectionText(self): def connectionText(self) -> str:
return self._connection_text return self._connection_text
def materialHotendChangedMessage(self, callback): def materialHotendChangedMessage(self, callback: Callable[[int], None]) -> None:
Logger.log("w", "materialHotendChangedMessage needs to be implemented, returning 'Yes'") Logger.log("w", "materialHotendChangedMessage needs to be implemented, returning 'Yes'")
callback(QMessageBox.Yes) callback(QMessageBox.Yes)
def isConnected(self): def isConnected(self) -> bool:
return self._connection_state != ConnectionState.closed and self._connection_state != ConnectionState.error return self._connection_state != ConnectionState.Closed and self._connection_state != ConnectionState.Error
def setConnectionState(self, connection_state): def setConnectionState(self, connection_state: "ConnectionState") -> None:
if self._connection_state != connection_state: if self._connection_state != connection_state:
self._connection_state = connection_state self._connection_state = connection_state
self.connectionStateChanged.emit(self._id) self.connectionStateChanged.emit(self._id)
@pyqtProperty(str, notify = connectionStateChanged) @pyqtProperty(int, constant = True)
def connectionState(self): def connectionType(self) -> "ConnectionType":
return self._connection_type
@pyqtProperty(int, notify = connectionStateChanged)
def connectionState(self) -> "ConnectionState":
return self._connection_state return self._connection_state
def _update(self): def _update(self) -> None:
pass pass
def _getPrinterByKey(self, key) -> Optional["PrinterOutputModel"]: def _getPrinterByKey(self, key: str) -> Optional["PrinterOutputModel"]:
for printer in self._printers: for printer in self._printers:
if printer.key == key: if printer.key == key:
return printer return printer
return None return None
def requestWrite(self, nodes, file_name = None, filter_by_machine = False, file_handler = None, **kwargs): def requestWrite(self, nodes: List["SceneNode"], file_name: Optional[str] = None, limit_mimetypes: bool = False,
file_handler: Optional["FileHandler"] = None, **kwargs: str) -> None:
raise NotImplementedError("requestWrite needs to be implemented") raise NotImplementedError("requestWrite needs to be implemented")
@pyqtProperty(QObject, notify = printersChanged) @pyqtProperty(QObject, notify = printersChanged)
@ -126,11 +154,11 @@ class PrinterOutputDevice(QObject, OutputDevice):
return None return None
@pyqtProperty("QVariantList", notify = printersChanged) @pyqtProperty("QVariantList", notify = printersChanged)
def printers(self): def printers(self) -> List["PrinterOutputModel"]:
return self._printers return self._printers
@pyqtProperty(QObject, constant=True) @pyqtProperty(QObject, constant = True)
def monitorItem(self): def monitorItem(self) -> QObject:
# Note that we specifically only check if the monitor component is created. # Note that we specifically only check if the monitor component is created.
# It could be that it failed to actually create the qml item! If we check if the item was created, it will try to # It could be that it failed to actually create the qml item! If we check if the item was created, it will try to
# create the item (and fail) every time. # create the item (and fail) every time.
@ -138,49 +166,49 @@ class PrinterOutputDevice(QObject, OutputDevice):
self._createMonitorViewFromQML() self._createMonitorViewFromQML()
return self._monitor_item return self._monitor_item
@pyqtProperty(QObject, constant=True) @pyqtProperty(QObject, constant = True)
def controlItem(self): def controlItem(self) -> QObject:
if not self._control_component: if not self._control_component:
self._createControlViewFromQML() self._createControlViewFromQML()
return self._control_item return self._control_item
def _createControlViewFromQML(self): def _createControlViewFromQML(self) -> None:
if not self._control_view_qml_path: if not self._control_view_qml_path:
return return
if self._control_item is None: if self._control_item is None:
self._control_item = Application.getInstance().createQmlComponent(self._control_view_qml_path, {"OutputDevice": self}) self._control_item = QtApplication.getInstance().createQmlComponent(self._control_view_qml_path, {"OutputDevice": self})
def _createMonitorViewFromQML(self): def _createMonitorViewFromQML(self) -> None:
if not self._monitor_view_qml_path: if not self._monitor_view_qml_path:
return return
if self._monitor_item is None: if self._monitor_item is None:
self._monitor_item = Application.getInstance().createQmlComponent(self._monitor_view_qml_path, {"OutputDevice": self}) self._monitor_item = QtApplication.getInstance().createQmlComponent(self._monitor_view_qml_path, {"OutputDevice": self})
## Attempt to establish connection ## Attempt to establish connection
def connect(self): def connect(self) -> None:
self.setConnectionState(ConnectionState.connecting) self.setConnectionState(ConnectionState.Connecting)
self._update_timer.start() self._update_timer.start()
## Attempt to close the connection ## Attempt to close the connection
def close(self): def close(self) -> None:
self._update_timer.stop() self._update_timer.stop()
self.setConnectionState(ConnectionState.closed) self.setConnectionState(ConnectionState.Closed)
## Ensure that close gets called when object is destroyed ## Ensure that close gets called when object is destroyed
def __del__(self): def __del__(self) -> None:
self.close() self.close()
@pyqtProperty(bool, notify=acceptsCommandsChanged) @pyqtProperty(bool, notify = acceptsCommandsChanged)
def acceptsCommands(self): def acceptsCommands(self) -> bool:
return self._accepts_commands return self._accepts_commands
@deprecated("Please use the protected function instead", "3.2") @deprecated("Please use the protected function instead", "3.2")
def setAcceptsCommands(self, accepts_commands): def setAcceptsCommands(self, accepts_commands: bool) -> None:
self._setAcceptsCommands(accepts_commands) self._setAcceptsCommands(accepts_commands)
## Set a flag to signal the UI that the printer is not (yet) ready to receive commands ## Set a flag to signal the UI that the printer is not (yet) ready to receive commands
def _setAcceptsCommands(self, accepts_commands): def _setAcceptsCommands(self, accepts_commands: bool) -> None:
if self._accepts_commands != accepts_commands: if self._accepts_commands != accepts_commands:
self._accepts_commands = accepts_commands self._accepts_commands = accepts_commands
@ -188,15 +216,22 @@ class PrinterOutputDevice(QObject, OutputDevice):
# Returns the unique configurations of the printers within this output device # Returns the unique configurations of the printers within this output device
@pyqtProperty("QVariantList", notify = uniqueConfigurationsChanged) @pyqtProperty("QVariantList", notify = uniqueConfigurationsChanged)
def uniqueConfigurations(self): def uniqueConfigurations(self) -> List["PrinterConfigurationModel"]:
return self._unique_configurations return self._unique_configurations
def _updateUniqueConfigurations(self): def _updateUniqueConfigurations(self) -> None:
self._unique_configurations = list(set([printer.printerConfiguration for printer in self._printers if printer.printerConfiguration is not None])) self._unique_configurations = sorted(
self._unique_configurations.sort(key = lambda k: k.printerType) {printer.printerConfiguration for printer in self._printers if printer.printerConfiguration is not None},
key=lambda config: config.printerType,
)
self.uniqueConfigurationsChanged.emit() self.uniqueConfigurationsChanged.emit()
def _onPrintersChanged(self): # Returns the unique configurations of the printers within this output device
@pyqtProperty("QStringList", notify = uniqueConfigurationsChanged)
def uniquePrinterTypes(self) -> List[str]:
return list(sorted(set([configuration.printerType for configuration in self._unique_configurations])))
def _onPrintersChanged(self) -> None:
for printer in self._printers: for printer in self._printers:
printer.configurationChanged.connect(self._updateUniqueConfigurations) printer.configurationChanged.connect(self._updateUniqueConfigurations)
@ -205,21 +240,22 @@ class PrinterOutputDevice(QObject, OutputDevice):
## Set the device firmware name ## Set the device firmware name
# #
# \param name \type{str} The name of the firmware. # \param name The name of the firmware.
def _setFirmwareName(self, name): def _setFirmwareName(self, name: str) -> None:
self._firmware_name = name self._firmware_name = name
## Get the name of device firmware ## Get the name of device firmware
# #
# This name can be used to define device type # This name can be used to define device type
def getFirmwareName(self): def getFirmwareName(self) -> Optional[str]:
return self._firmware_name return self._firmware_name
def getFirmwareUpdater(self) -> Optional["FirmwareUpdater"]:
return self._firmware_updater
## The current processing state of the backend. @pyqtSlot(str)
class ConnectionState(IntEnum): def updateFirmware(self, firmware_file: Union[str, QUrl]) -> None:
closed = 0 if not self._firmware_updater:
connecting = 1 return
connected = 2
busy = 3 self._firmware_updater.updateFirmware(firmware_file)
error = 4

View file

@ -1,9 +1,12 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
class BlockSlicingDecorator(SceneNodeDecorator): class BlockSlicingDecorator(SceneNodeDecorator):
def __init__(self): def __init__(self) -> None:
super().__init__() super().__init__()
def isBlockSlicing(self): def isBlockSlicing(self) -> bool:
return True return True

View file

@ -5,6 +5,7 @@ from PyQt5.QtCore import QTimer
from UM.Application import Application from UM.Application import Application
from UM.Math.Polygon import Polygon from UM.Math.Polygon import Polygon
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.ContainerRegistry import ContainerRegistry
@ -13,39 +14,49 @@ from cura.Scene import ConvexHullNode
import numpy import numpy
from typing import TYPE_CHECKING, Any, Optional
if TYPE_CHECKING:
from UM.Scene.SceneNode import SceneNode
from cura.Settings.GlobalStack import GlobalStack
from UM.Mesh.MeshData import MeshData
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. ## 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. # 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): class ConvexHullDecorator(SceneNodeDecorator):
def __init__(self): def __init__(self) -> None:
super().__init__() super().__init__()
self._convex_hull_node = None self._convex_hull_node = None # type: Optional["SceneNode"]
self._init2DConvexHullCache() self._init2DConvexHullCache()
self._global_stack = None self._global_stack = None # type: Optional[GlobalStack]
# Make sure the timer is created on the main thread # Make sure the timer is created on the main thread
self._recompute_convex_hull_timer = None self._recompute_convex_hull_timer = None # type: Optional[QTimer]
Application.getInstance().callLater(self.createRecomputeConvexHullTimer) from cura.CuraApplication import CuraApplication
if CuraApplication.getInstance() is not None:
CuraApplication.getInstance().callLater(self.createRecomputeConvexHullTimer)
self._raft_thickness = 0.0 self._raft_thickness = 0.0
# For raft thickness, DRY self._build_volume = CuraApplication.getInstance().getBuildVolume()
self._build_volume = Application.getInstance().getBuildVolume()
self._build_volume.raftThicknessChanged.connect(self._onChanged) self._build_volume.raftThicknessChanged.connect(self._onChanged)
Application.getInstance().globalContainerStackChanged.connect(self._onGlobalStackChanged) CuraApplication.getInstance().globalContainerStackChanged.connect(self._onGlobalStackChanged)
Application.getInstance().getController().toolOperationStarted.connect(self._onChanged) CuraApplication.getInstance().getController().toolOperationStarted.connect(self._onChanged)
Application.getInstance().getController().toolOperationStopped.connect(self._onChanged) CuraApplication.getInstance().getController().toolOperationStopped.connect(self._onChanged)
self._onGlobalStackChanged() self._onGlobalStackChanged()
def createRecomputeConvexHullTimer(self): def createRecomputeConvexHullTimer(self) -> None:
self._recompute_convex_hull_timer = QTimer() self._recompute_convex_hull_timer = QTimer()
self._recompute_convex_hull_timer.setInterval(200) self._recompute_convex_hull_timer.setInterval(200)
self._recompute_convex_hull_timer.setSingleShot(True) self._recompute_convex_hull_timer.setSingleShot(True)
self._recompute_convex_hull_timer.timeout.connect(self.recomputeConvexHull) self._recompute_convex_hull_timer.timeout.connect(self.recomputeConvexHull)
def setNode(self, node): def setNode(self, node: "SceneNode") -> None:
previous_node = self._node previous_node = self._node
# Disconnect from previous node signals # Disconnect from previous node signals
if previous_node is not None and node is not previous_node: if previous_node is not None and node is not previous_node:
@ -53,9 +64,9 @@ class ConvexHullDecorator(SceneNodeDecorator):
previous_node.parentChanged.disconnect(self._onChanged) previous_node.parentChanged.disconnect(self._onChanged)
super().setNode(node) super().setNode(node)
# Mypy doesn't understand that self._node is no longer optional, so just use the node.
self._node.transformationChanged.connect(self._onChanged) node.transformationChanged.connect(self._onChanged)
self._node.parentChanged.connect(self._onChanged) node.parentChanged.connect(self._onChanged)
self._onChanged() self._onChanged()
@ -63,37 +74,46 @@ class ConvexHullDecorator(SceneNodeDecorator):
def __deepcopy__(self, memo): def __deepcopy__(self, memo):
return ConvexHullDecorator() return ConvexHullDecorator()
## Get the unmodified 2D projected convex hull of the node ## Get the unmodified 2D projected convex hull of the node (if any)
def getConvexHull(self): def getConvexHull(self) -> Optional[Polygon]:
if self._node is None: if self._node is None:
return None return None
hull = self._compute2DConvexHull() hull = self._compute2DConvexHull()
if self._global_stack and self._node: if self._global_stack and self._node is not None and hull is not None:
# Parent can be None if node is just loaded. # Parent can be None if node is just loaded.
if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and (self._node.getParent() is None or not self._node.getParent().callDecoration("isGroup")): if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and not self.hasGroupAsParent(self._node):
hull = hull.getMinkowskiHull(Polygon(numpy.array(self._global_stack.getProperty("machine_head_polygon", "value"), numpy.float32))) hull = hull.getMinkowskiHull(Polygon(numpy.array(self._global_stack.getProperty("machine_head_polygon", "value"), numpy.float32)))
hull = self._add2DAdhesionMargin(hull) hull = self._add2DAdhesionMargin(hull)
return hull return hull
## Get the convex hull of the node with the full head size ## Get the convex hull of the node with the full head size
def getConvexHullHeadFull(self): def getConvexHullHeadFull(self) -> Optional[Polygon]:
if self._node is None: if self._node is None:
return None return None
return self._compute2DConvexHeadFull() return self._compute2DConvexHeadFull()
@staticmethod
def hasGroupAsParent(node: "SceneNode") -> bool:
parent = node.getParent()
if parent is None:
return False
return bool(parent.callDecoration("isGroup"))
## Get convex hull of the object + head size ## Get convex hull of the object + head size
# In case of printing all at once this is the same as the convex hull. # In case of printing all at once this is the same as the convex hull.
# For one at the time this is area with intersection of mirrored head # For one at the time this is area with intersection of mirrored head
def getConvexHullHead(self): def getConvexHullHead(self) -> Optional[Polygon]:
if self._node is None: if self._node is None:
return None return None
if self._global_stack: if self._global_stack:
if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and (self._node.getParent() is None or not self._node.getParent().callDecoration("isGroup")): if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and not self.hasGroupAsParent(self._node):
head_with_fans = self._compute2DConvexHeadMin() head_with_fans = self._compute2DConvexHeadMin()
if head_with_fans is None:
return None
head_with_fans_with_adhesion_margin = self._add2DAdhesionMargin(head_with_fans) head_with_fans_with_adhesion_margin = self._add2DAdhesionMargin(head_with_fans)
return head_with_fans_with_adhesion_margin return head_with_fans_with_adhesion_margin
return None return None
@ -101,26 +121,33 @@ class ConvexHullDecorator(SceneNodeDecorator):
## Get convex hull of the node ## Get convex hull of the node
# In case of printing all at once this is the same as the convex hull. # In case of printing all at once this is the same as the convex hull.
# For one at the time this is the area without the head. # For one at the time this is the area without the head.
def getConvexHullBoundary(self): def getConvexHullBoundary(self) -> Optional[Polygon]:
if self._node is None: if self._node is None:
return None return None
if self._global_stack: if self._global_stack:
if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and (self._node.getParent() is None or not self._node.getParent().callDecoration("isGroup")): if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and not self.hasGroupAsParent(self._node):
# Printing one at a time and it's not an object in a group # Printing one at a time and it's not an object in a group
return self._compute2DConvexHull() return self._compute2DConvexHull()
return None return None
def recomputeConvexHullDelayed(self): ## The same as recomputeConvexHull, but using a timer if it was set.
def recomputeConvexHullDelayed(self) -> None:
if self._recompute_convex_hull_timer is not None: if self._recompute_convex_hull_timer is not None:
self._recompute_convex_hull_timer.start() self._recompute_convex_hull_timer.start()
else: else:
self.recomputeConvexHull() self.recomputeConvexHull()
def recomputeConvexHull(self): def recomputeConvexHull(self) -> None:
controller = Application.getInstance().getController() controller = Application.getInstance().getController()
root = controller.getScene().getRoot() root = controller.getScene().getRoot()
if self._node is None or controller.isToolOperationActive() or not self.__isDescendant(root, self._node): 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._convex_hull_node: if self._convex_hull_node:
self._convex_hull_node.setParent(None) self._convex_hull_node.setParent(None)
self._convex_hull_node = None self._convex_hull_node = None
@ -132,33 +159,38 @@ class ConvexHullDecorator(SceneNodeDecorator):
hull_node = ConvexHullNode.ConvexHullNode(self._node, convex_hull, self._raft_thickness, root) hull_node = ConvexHullNode.ConvexHullNode(self._node, convex_hull, self._raft_thickness, root)
self._convex_hull_node = hull_node self._convex_hull_node = hull_node
def _onSettingValueChanged(self, key, property_name): def _onSettingValueChanged(self, key: str, property_name: str) -> None:
if property_name != "value": #Not the value that was changed. if property_name != "value": # Not the value that was changed.
return return
if key in self._affected_settings: if key in self._affected_settings:
self._onChanged() self._onChanged()
if key in self._influencing_settings: if key in self._influencing_settings:
self._init2DConvexHullCache() #Invalidate the cache. self._init2DConvexHullCache() # Invalidate the cache.
self._onChanged() self._onChanged()
def _init2DConvexHullCache(self): def _init2DConvexHullCache(self) -> None:
# Cache for the group code path in _compute2DConvexHull() # Cache for the group code path in _compute2DConvexHull()
self._2d_convex_hull_group_child_polygon = None self._2d_convex_hull_group_child_polygon = None # type: Optional[Polygon]
self._2d_convex_hull_group_result = None self._2d_convex_hull_group_result = None # type: Optional[Polygon]
# Cache for the mesh code path in _compute2DConvexHull() # Cache for the mesh code path in _compute2DConvexHull()
self._2d_convex_hull_mesh = None self._2d_convex_hull_mesh = None # type: Optional[MeshData]
self._2d_convex_hull_mesh_world_transform = None self._2d_convex_hull_mesh_world_transform = None # type: Optional[Matrix]
self._2d_convex_hull_mesh_result = None self._2d_convex_hull_mesh_result = None # type: Optional[Polygon]
def _compute2DConvexHull(self): def _compute2DConvexHull(self) -> Optional[Polygon]:
if self._node is None:
return None
if self._node.callDecoration("isGroup"): 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(): for child in self._node.getChildren():
child_hull = child.callDecoration("_compute2DConvexHull") child_hull = child.callDecoration("_compute2DConvexHull")
if child_hull: if child_hull:
try:
points = numpy.append(points, child_hull.getPoints(), axis = 0) points = numpy.append(points, child_hull.getPoints(), axis = 0)
except ValueError:
pass
if points.size < 3: if points.size < 3:
return None return None
@ -178,11 +210,11 @@ class ConvexHullDecorator(SceneNodeDecorator):
return offset_hull return offset_hull
else: else:
offset_hull = None offset_hull = Polygon([])
mesh = None
world_transform = None
if self._node.getMeshData():
mesh = self._node.getMeshData() 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()
# Check the cache # Check the cache
@ -195,7 +227,7 @@ class ConvexHullDecorator(SceneNodeDecorator):
# Do not throw away vertices: the convex hull may be too small and objects can collide. # 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] # vertex_data = vertex_data[vertex_data[:,1] >= -0.01]
if len(vertex_data) >= 4: if 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 # 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 # 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. # becomes much less complex when dealing with highly detailed models.
@ -210,7 +242,7 @@ class ConvexHullDecorator(SceneNodeDecorator):
# See http://stackoverflow.com/questions/16970982/find-unique-rows-in-numpy-array # See http://stackoverflow.com/questions/16970982/find-unique-rows-in-numpy-array
vertex_byte_view = numpy.ascontiguousarray(vertex_data).view( vertex_byte_view = numpy.ascontiguousarray(vertex_data).view(
numpy.dtype((numpy.void, vertex_data.dtype.itemsize * vertex_data.shape[1]))) numpy.dtype((numpy.void, vertex_data.dtype.itemsize * vertex_data.shape[1])))
_, idx = numpy.unique(vertex_byte_view, return_index=True) _, idx = numpy.unique(vertex_byte_view, return_index = True)
vertex_data = vertex_data[idx] # Select the unique rows by index. vertex_data = vertex_data[idx] # Select the unique rows by index.
hull = Polygon(vertex_data) hull = Polygon(vertex_data)
@ -218,8 +250,6 @@ class ConvexHullDecorator(SceneNodeDecorator):
if len(vertex_data) >= 3: if len(vertex_data) >= 3:
convex_hull = hull.getConvexHull() convex_hull = hull.getConvexHull()
offset_hull = self._offsetHull(convex_hull) offset_hull = self._offsetHull(convex_hull)
else:
return Polygon([]) # Node has no mesh data, so just return an empty Polygon.
# Store the result in the cache # Store the result in the cache
self._2d_convex_hull_mesh = mesh self._2d_convex_hull_mesh = mesh
@ -228,42 +258,56 @@ class ConvexHullDecorator(SceneNodeDecorator):
return offset_hull return offset_hull
def _getHeadAndFans(self): def _getHeadAndFans(self) -> Polygon:
return Polygon(numpy.array(self._global_stack.getProperty("machine_head_with_fans_polygon", "value"), numpy.float32)) if self._global_stack:
return Polygon(numpy.array(self._global_stack.getHeadAndFansCoordinates(), numpy.float32))
return Polygon()
def _compute2DConvexHeadFull(self): def _compute2DConvexHeadFull(self) -> Optional[Polygon]:
return self._compute2DConvexHull().getMinkowskiHull(self._getHeadAndFans()) convex_hull = self._compute2DConvexHull()
if convex_hull:
return convex_hull.getMinkowskiHull(self._getHeadAndFans())
return None
def _compute2DConvexHeadMin(self): def _compute2DConvexHeadMin(self) -> Optional[Polygon]:
headAndFans = self._getHeadAndFans() head_and_fans = self._getHeadAndFans()
mirrored = headAndFans.mirror([0, 0], [0, 1]).mirror([0, 0], [1, 0]) # Mirror horizontally & vertically. mirrored = head_and_fans.mirror([0, 0], [0, 1]).mirror([0, 0], [1, 0]) # Mirror horizontally & vertically.
head_and_fans = self._getHeadAndFans().intersectionConvexHulls(mirrored) head_and_fans = self._getHeadAndFans().intersectionConvexHulls(mirrored)
# Min head hull is used for the push free # Min head hull is used for the push free
min_head_hull = self._compute2DConvexHull().getMinkowskiHull(head_and_fans) convex_hull = self._compute2DConvexHull()
return min_head_hull if convex_hull:
return convex_hull.getMinkowskiHull(head_and_fans)
return None
## Compensate given 2D polygon with adhesion margin ## Compensate given 2D polygon with adhesion margin
# \return 2D polygon with added margin # \return 2D polygon with added margin
def _add2DAdhesionMargin(self, poly): def _add2DAdhesionMargin(self, poly: Polygon) -> Polygon:
if not self._global_stack:
return Polygon()
# Compensate for raft/skirt/brim # Compensate for raft/skirt/brim
# Add extra margin depending on adhesion type # Add extra margin depending on adhesion type
adhesion_type = self._global_stack.getProperty("adhesion_type", "value") adhesion_type = self._global_stack.getProperty("adhesion_type", "value")
max_length_available = 0.5 * min(
self._getSettingProperty("machine_width", "value"),
self._getSettingProperty("machine_depth", "value")
)
if adhesion_type == "raft": if adhesion_type == "raft":
extra_margin = max(0, self._getSettingProperty("raft_margin", "value")) extra_margin = min(max_length_available, max(0, self._getSettingProperty("raft_margin", "value")))
elif adhesion_type == "brim": elif adhesion_type == "brim":
extra_margin = max(0, self._getSettingProperty("brim_line_count", "value") * self._getSettingProperty("skirt_brim_line_width", "value")) extra_margin = min(max_length_available, max(0, self._getSettingProperty("brim_line_count", "value") * self._getSettingProperty("skirt_brim_line_width", "value")))
elif adhesion_type == "none": elif adhesion_type == "none":
extra_margin = 0 extra_margin = 0
elif adhesion_type == "skirt": elif adhesion_type == "skirt":
extra_margin = max( extra_margin = min(max_length_available, max(
0, self._getSettingProperty("skirt_gap", "value") + 0, self._getSettingProperty("skirt_gap", "value") +
self._getSettingProperty("skirt_line_count", "value") * self._getSettingProperty("skirt_brim_line_width", "value")) self._getSettingProperty("skirt_line_count", "value") * self._getSettingProperty("skirt_brim_line_width", "value")))
else: else:
raise Exception("Unknown bed adhesion type. Did you forget to update the convex hull calculations for your new bed adhesion type?") raise Exception("Unknown bed adhesion type. Did you forget to update the convex hull calculations for your new bed adhesion type?")
# adjust head_and_fans with extra margin # Adjust head_and_fans with extra margin
if extra_margin > 0: if extra_margin > 0:
extra_margin_polygon = Polygon.approximatedCircle(extra_margin) extra_margin_polygon = Polygon.approximatedCircle(extra_margin)
poly = poly.getMinkowskiHull(extra_margin_polygon) poly = poly.getMinkowskiHull(extra_margin_polygon)
@ -274,7 +318,7 @@ class ConvexHullDecorator(SceneNodeDecorator):
# \param convex_hull Polygon of the original convex hull. # \param convex_hull Polygon of the original convex hull.
# \return New Polygon instance that is offset with everything that # \return New Polygon instance that is offset with everything that
# influences the collision area. # influences the collision area.
def _offsetHull(self, convex_hull): def _offsetHull(self, convex_hull: Polygon) -> Polygon:
horizontal_expansion = max( horizontal_expansion = max(
self._getSettingProperty("xy_offset", "value"), self._getSettingProperty("xy_offset", "value"),
self._getSettingProperty("xy_offset_layer_0", "value") self._getSettingProperty("xy_offset_layer_0", "value")
@ -295,16 +339,16 @@ class ConvexHullDecorator(SceneNodeDecorator):
else: else:
return convex_hull return convex_hull
def _onChanged(self, *args): def _onChanged(self, *args) -> None:
self._raft_thickness = self._build_volume.getRaftThickness() self._raft_thickness = self._build_volume.getRaftThickness()
if not args or args[0] == self._node: if not args or args[0] == self._node:
self.recomputeConvexHullDelayed() self.recomputeConvexHullDelayed()
def _onGlobalStackChanged(self): def _onGlobalStackChanged(self) -> None:
if self._global_stack: if self._global_stack:
self._global_stack.propertyChanged.disconnect(self._onSettingValueChanged) self._global_stack.propertyChanged.disconnect(self._onSettingValueChanged)
self._global_stack.containersChanged.disconnect(self._onChanged) self._global_stack.containersChanged.disconnect(self._onChanged)
extruders = ExtruderManager.getInstance().getMachineExtruders(self._global_stack.getId()) extruders = ExtruderManager.getInstance().getActiveExtruderStacks()
for extruder in extruders: for extruder in extruders:
extruder.propertyChanged.disconnect(self._onSettingValueChanged) extruder.propertyChanged.disconnect(self._onSettingValueChanged)
@ -314,14 +358,16 @@ class ConvexHullDecorator(SceneNodeDecorator):
self._global_stack.propertyChanged.connect(self._onSettingValueChanged) self._global_stack.propertyChanged.connect(self._onSettingValueChanged)
self._global_stack.containersChanged.connect(self._onChanged) self._global_stack.containersChanged.connect(self._onChanged)
extruders = ExtruderManager.getInstance().getMachineExtruders(self._global_stack.getId()) extruders = ExtruderManager.getInstance().getActiveExtruderStacks()
for extruder in extruders: for extruder in extruders:
extruder.propertyChanged.connect(self._onSettingValueChanged) extruder.propertyChanged.connect(self._onSettingValueChanged)
self._onChanged() self._onChanged()
## Private convenience function to get a setting from the correct extruder (as defined by limit_to_extruder property). ## Private convenience function to get a setting from the correct extruder (as defined by limit_to_extruder property).
def _getSettingProperty(self, setting_key, prop = "value"): def _getSettingProperty(self, setting_key: str, prop: str = "value") -> Any:
if self._global_stack is None or self._node is None:
return None
per_mesh_stack = self._node.callDecoration("getStack") per_mesh_stack = self._node.callDecoration("getStack")
if per_mesh_stack: if per_mesh_stack:
return per_mesh_stack.getProperty(setting_key, prop) return per_mesh_stack.getProperty(setting_key, prop)
@ -339,8 +385,8 @@ class ConvexHullDecorator(SceneNodeDecorator):
# Limit_to_extruder is set. The global stack handles this then # Limit_to_extruder is set. The global stack handles this then
return self._global_stack.getProperty(setting_key, prop) return self._global_stack.getProperty(setting_key, prop)
## Returns true if node is a descendant or the same as the root node. ## Returns True if node is a descendant or the same as the root node.
def __isDescendant(self, root, node): def __isDescendant(self, root: "SceneNode", node: Optional["SceneNode"]) -> bool:
if node is None: if node is None:
return False return False
if root is node: if root is node:

View file

@ -1,7 +1,10 @@
# Copyright (c) 2015 Ultimaker B.V. # Copyright (c) 2015 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
from UM.Application import Application from UM.Application import Application
from UM.Math.Polygon import Polygon
from UM.Qt.QtApplication import QtApplication
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Resources import Resources from UM.Resources import Resources
from UM.Math.Color import Color from UM.Math.Color import Color
@ -16,7 +19,7 @@ class ConvexHullNode(SceneNode):
# location an object uses on the buildplate. This area (or area's in case of one at a time printing) is # 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 # then displayed as a transparent shadow. If the adhesion type is set to raft, the area is extruded
# to represent the raft as well. # to represent the raft as well.
def __init__(self, node, hull, thickness, parent = None): def __init__(self, node: SceneNode, hull: Optional[Polygon], thickness: float, parent: Optional[SceneNode] = None) -> None:
super().__init__(parent) super().__init__(parent)
self.setCalculateBoundingBox(False) self.setCalculateBoundingBox(False)
@ -24,8 +27,12 @@ class ConvexHullNode(SceneNode):
self._original_parent = parent self._original_parent = parent
# Color of the drawn convex hull # Color of the drawn convex hull
if Application.getInstance().hasGui(): if not Application.getInstance().getIsHeadLess():
self._color = Color(*Application.getInstance().getTheme().getColor("convex_hull").getRgb()) theme = QtApplication.getInstance().getTheme()
if theme:
self._color = Color(*theme.getColor("convex_hull").getRgb())
else:
self._color = Color(0, 0, 0)
else: else:
self._color = Color(0, 0, 0) self._color = Color(0, 0, 0)
@ -47,7 +54,7 @@ class ConvexHullNode(SceneNode):
if hull_mesh_builder.addConvexPolygonExtrusion( if hull_mesh_builder.addConvexPolygonExtrusion(
self._hull.getPoints()[::-1], # bottom layer is reversed self._hull.getPoints()[::-1], # bottom layer is reversed
self._mesh_height-thickness, self._mesh_height, color=self._color): self._mesh_height - thickness, self._mesh_height, color = self._color):
hull_mesh = hull_mesh_builder.build() hull_mesh = hull_mesh_builder.build()
self.setMeshData(hull_mesh) self.setMeshData(hull_mesh)
@ -75,7 +82,7 @@ class ConvexHullNode(SceneNode):
return True return True
def _onNodeDecoratorsChanged(self, node): def _onNodeDecoratorsChanged(self, node: SceneNode) -> None:
convex_hull_head = self._node.callDecoration("getConvexHullHead") convex_hull_head = self._node.callDecoration("getConvexHullHead")
if convex_hull_head: if convex_hull_head:
convex_hull_head_builder = MeshBuilder() convex_hull_head_builder = MeshBuilder()

View file

@ -3,7 +3,8 @@ from UM.Logger import Logger
from PyQt5.QtCore import Qt, pyqtSlot, QObject from PyQt5.QtCore import Qt, pyqtSlot, QObject
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
from cura.ObjectsModel import ObjectsModel from UM.Scene.Camera import Camera
from cura.UI.ObjectsModel import ObjectsModel
from cura.Machines.Models.MultiBuildPlateModel import MultiBuildPlateModel from cura.Machines.Models.MultiBuildPlateModel import MultiBuildPlateModel
from UM.Application import Application from UM.Application import Application
@ -16,7 +17,7 @@ from UM.Signal import Signal
class CuraSceneController(QObject): class CuraSceneController(QObject):
activeBuildPlateChanged = Signal() activeBuildPlateChanged = Signal()
def __init__(self, objects_model: ObjectsModel, multi_build_plate_model: MultiBuildPlateModel): def __init__(self, objects_model: ObjectsModel, multi_build_plate_model: MultiBuildPlateModel) -> None:
super().__init__() super().__init__()
self._objects_model = objects_model self._objects_model = objects_model
@ -33,7 +34,7 @@ class CuraSceneController(QObject):
source = args[0] source = args[0]
else: else:
source = None source = None
if not isinstance(source, SceneNode): if not isinstance(source, SceneNode) or isinstance(source, Camera):
return return
max_build_plate = self._calcMaxBuildPlate() max_build_plate = self._calcMaxBuildPlate()
changed = False changed = False

View file

@ -1,40 +1,47 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from copy import deepcopy from copy import deepcopy
from typing import List from typing import cast, Dict, List, Optional
from UM.Application import Application from UM.Application import Application
from UM.Math.AxisAlignedBox import AxisAlignedBox from UM.Math.AxisAlignedBox import AxisAlignedBox
from UM.Math.Polygon import Polygon #For typing.
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator #To cast the deepcopy of every decorator back to SceneNodeDecorator.
from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator import cura.CuraApplication #To get the build plate.
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 ## 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. # Note that many other nodes can just be UM SceneNode objects.
class CuraSceneNode(SceneNode): class CuraSceneNode(SceneNode):
def __init__(self, *args, **kwargs): def __init__(self, parent: Optional["SceneNode"] = None, visible: bool = True, name: str = "", no_setting_override: bool = False) -> None:
super().__init__(*args, **kwargs) super().__init__(parent = parent, visible = visible, name = name)
if "no_setting_override" not in kwargs: if not no_setting_override:
self.addDecorator(SettingOverrideDecorator()) # now we always have a getActiveExtruderPosition, unless explicitly disabled self.addDecorator(SettingOverrideDecorator()) # now we always have a getActiveExtruderPosition, unless explicitly disabled
self._outside_buildarea = False self._outside_buildarea = False
def setOutsideBuildArea(self, new_value): def setOutsideBuildArea(self, new_value: bool) -> None:
self._outside_buildarea = new_value self._outside_buildarea = new_value
def isOutsideBuildArea(self): def isOutsideBuildArea(self) -> bool:
return self._outside_buildarea or self.callDecoration("getBuildPlateNumber") < 0 return self._outside_buildarea or self.callDecoration("getBuildPlateNumber") < 0
def isVisible(self): def isVisible(self) -> bool:
return super().isVisible() and self.callDecoration("getBuildPlateNumber") == Application.getInstance().getMultiBuildPlateModel().activeBuildPlate return super().isVisible() and self.callDecoration("getBuildPlateNumber") == cura.CuraApplication.CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate
def isSelectable(self) -> bool: def isSelectable(self) -> bool:
return super().isSelectable() and self.callDecoration("getBuildPlateNumber") == Application.getInstance().getMultiBuildPlateModel().activeBuildPlate 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 ## 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 # TODO The best way to do it is by adding the setActiveExtruder decorator to every node when is loaded
def getPrintingExtruder(self): def getPrintingExtruder(self) -> Optional[ExtruderStack]:
global_container_stack = Application.getInstance().getGlobalContainerStack() global_container_stack = Application.getInstance().getGlobalContainerStack()
if global_container_stack is None:
return None
per_mesh_stack = self.callDecoration("getStack") per_mesh_stack = self.callDecoration("getStack")
extruders = list(global_container_stack.extruders.values()) extruders = list(global_container_stack.extruders.values())
@ -79,9 +86,9 @@ class CuraSceneNode(SceneNode):
] ]
## Return if the provided bbox collides with the bbox of this scene node ## Return if the provided bbox collides with the bbox of this scene node
def collidesWithBbox(self, check_bbox): def collidesWithBbox(self, check_bbox: AxisAlignedBox) -> bool:
bbox = self.getBoundingBox() bbox = self.getBoundingBox()
if bbox is not None:
# Mark the node as outside the build volume if the bounding box test fails. # Mark the node as outside the build volume if the bounding box test fails.
if check_bbox.intersectsBox(bbox) != AxisAlignedBox.IntersectionResult.FullIntersection: if check_bbox.intersectsBox(bbox) != AxisAlignedBox.IntersectionResult.FullIntersection:
return True return True
@ -89,7 +96,7 @@ class CuraSceneNode(SceneNode):
return False return False
## Return if any area collides with the convex hull of this scene node ## Return if any area collides with the convex hull of this scene node
def collidesWithArea(self, areas): def collidesWithArea(self, areas: List[Polygon]) -> bool:
convex_hull = self.callDecoration("getConvexHull") convex_hull = self.callDecoration("getConvexHull")
if convex_hull: if convex_hull:
if not convex_hull.isValid(): if not convex_hull.isValid():
@ -104,37 +111,36 @@ class CuraSceneNode(SceneNode):
return False return False
## Override of SceneNode._calculateAABB to exclude non-printing-meshes from bounding box ## Override of SceneNode._calculateAABB to exclude non-printing-meshes from bounding box
def _calculateAABB(self): def _calculateAABB(self) -> None:
aabb = None self._aabb = None
if self._mesh_data: if self._mesh_data:
aabb = self._mesh_data.getExtents(self.getWorldTransformation()) 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)
position = self.getWorldPosition()
aabb = AxisAlignedBox(minimum = position, maximum = position)
for child in self._children: for child in self._children:
if child.callDecoration("isNonPrintingMesh"): if child.callDecoration("isNonPrintingMesh"):
# Non-printing-meshes inside a group should not affect push apart or drop to build plate # Non-printing-meshes inside a group should not affect push apart or drop to build plate
continue continue
if aabb is None: if not child._mesh_data:
aabb = child.getBoundingBox() # Nodes without mesh data should not affect bounding boxes of their parents.
continue
if self._aabb is None:
self._aabb = child.getBoundingBox()
else: else:
aabb = aabb + child.getBoundingBox() self._aabb = self._aabb + child.getBoundingBox()
self._aabb = aabb
## Taken from SceneNode, but replaced SceneNode with CuraSceneNode ## Taken from SceneNode, but replaced SceneNode with CuraSceneNode
def __deepcopy__(self, memo): def __deepcopy__(self, memo: Dict[int, object]) -> "CuraSceneNode":
copy = CuraSceneNode(no_setting_override = True) # Setting override will be added later copy = CuraSceneNode(no_setting_override = True) # Setting override will be added later
copy.setTransformation(self.getLocalTransformation()) copy.setTransformation(self.getLocalTransformation())
copy.setMeshData(self._mesh_data) copy.setMeshData(self._mesh_data)
copy.setVisible(deepcopy(self._visible, memo)) copy.setVisible(cast(bool, deepcopy(self._visible, memo)))
copy._selectable = deepcopy(self._selectable, memo) copy._selectable = cast(bool, deepcopy(self._selectable, memo))
copy._name = deepcopy(self._name, memo) copy._name = cast(str, deepcopy(self._name, memo))
for decorator in self._decorators: for decorator in self._decorators:
copy.addDecorator(deepcopy(decorator, memo)) copy.addDecorator(cast(SceneNodeDecorator, deepcopy(decorator, memo)))
for child in self._children: for child in self._children:
copy.addChild(deepcopy(child, memo)) copy.addChild(cast(SceneNode, deepcopy(child, memo)))
self.calculateBoundingBoxMesh() self.calculateBoundingBoxMesh()
return copy return copy

View file

@ -1,13 +1,19 @@
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
from typing import List
class GCodeListDecorator(SceneNodeDecorator): class GCodeListDecorator(SceneNodeDecorator):
def __init__(self): def __init__(self) -> None:
super().__init__() super().__init__()
self._gcode_list = [] self._gcode_list = [] # type: List[str]
def getGCodeList(self): def getGCodeList(self) -> List[str]:
return self._gcode_list return self._gcode_list
def setGCodeList(self, list): def setGCodeList(self, list: List[str]) -> None:
self._gcode_list = list self._gcode_list = list
def __deepcopy__(self, memo) -> "GCodeListDecorator":
copied_decorator = GCodeListDecorator()
copied_decorator.setGCodeList(self.getGCodeList())
return copied_decorator

View file

@ -2,11 +2,11 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
class SliceableObjectDecorator(SceneNodeDecorator): class SliceableObjectDecorator(SceneNodeDecorator):
def __init__(self): def __init__(self) -> None:
super().__init__() super().__init__()
def isSliceable(self): def isSliceable(self) -> bool:
return True return True
def __deepcopy__(self, memo): def __deepcopy__(self, memo) -> "SliceableObjectDecorator":
return type(self)() return type(self)()

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