Merge branch 'master' into ppscript_pre_secure

This commit is contained in:
Nino van Hooff 2020-03-24 11:37:53 +01:00
commit 7f89c7e740
484 changed files with 98147 additions and 70366 deletions

View File

@ -5,8 +5,8 @@ include(GNUInstallDirs)
list(APPEND CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/cmake)
set(URANIUM_DIR "${CMAKE_SOURCE_DIR}/../Uranium" CACHE DIRECTORY "The location of the Uranium repository")
set(URANIUM_SCRIPTS_DIR "${URANIUM_DIR}/scripts" CACHE DIRECTORY "The location of the scripts directory of the Uranium repository")
set(URANIUM_DIR "${CMAKE_SOURCE_DIR}/../Uranium" CACHE PATH "The location of the Uranium repository")
set(URANIUM_SCRIPTS_DIR "${URANIUM_DIR}/scripts" CACHE PATH "The location of the scripts directory of the Uranium repository")
# Tests
include(CuraTests)
@ -23,6 +23,7 @@ set(CURA_BUILDTYPE "" CACHE STRING "Build type of Cura, eg. 'PPA'")
set(CURA_CLOUD_API_ROOT "" CACHE STRING "Alternative Cura cloud API root")
set(CURA_CLOUD_API_VERSION "" CACHE STRING "Alternative Cura cloud API version")
set(CURA_CLOUD_ACCOUNT_API_ROOT "" CACHE STRING "Alternative Cura cloud account API version")
set(CURA_MARKETPLACE_ROOT "" CACHE STRING "Alternative Marketplace location")
configure_file(${CMAKE_SOURCE_DIR}/cura.desktop.in ${CMAKE_BINARY_DIR}/cura.desktop @ONLY)

View File

@ -56,6 +56,13 @@ function(cura_add_test)
endif()
endfunction()
#Add test for import statements which are not compatible with all builds
add_test(
NAME "invalid-imports"
COMMAND ${Python3_EXECUTABLE} scripts/check_invalid_imports.py
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
)
cura_add_test(NAME pytest-main DIRECTORY ${CMAKE_SOURCE_DIR}/tests PYTHONPATH "${CMAKE_SOURCE_DIR}|${URANIUM_DIR}")
file(GLOB_RECURSE _plugins plugins/*/__init__.py)

View File

@ -28,11 +28,12 @@ class CuraAPI(QObject):
# 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
if cls.__instance is not None:
raise RuntimeError("Tried to create singleton '{class_name}' more than once.".format(class_name = CuraAPI.__name__))
if application is None:
raise RuntimeError("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:

View File

@ -1,4 +1,4 @@
# Copyright (c) 2018 Ultimaker B.V.
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
# ---------
@ -13,7 +13,7 @@ DEFAULT_CURA_DEBUG_MODE = False
# Each release has a fixed SDK version coupled with it. It doesn't make sense to make it configurable because, for
# example Cura 3.2 with SDK version 6.1 will not work. So the SDK version is hard-coded here and left out of the
# CuraVersion.py.in template.
CuraSDKVersion = "7.0.0"
CuraSDKVersion = "7.1.0"
try:
from cura.CuraVersion import CuraAppName # type: ignore

View File

@ -173,7 +173,10 @@ class Arrange:
def bestSpot(self, shape_arr, start_prio = 0, step = 1):
start_idx_list = numpy.where(self._priority_unique_values == start_prio)
if start_idx_list:
start_idx = start_idx_list[0][0]
try:
start_idx = start_idx_list[0][0]
except IndexError:
start_idx = 0
else:
start_idx = 0
for priority in self._priority_unique_values[start_idx::step]:

View File

@ -145,6 +145,14 @@ class Backup:
# \return Whether we had success or not.
@staticmethod
def _extractArchive(archive: "ZipFile", target_path: str) -> bool:
# Implement security recommendations: Sanity check on zip files will make it harder to spoof.
from cura.CuraApplication import CuraApplication
config_filename = CuraApplication.getInstance().getApplicationName() + ".cfg" # Should be there if valid.
if config_filename not in [file.filename for file in archive.filelist]:
Logger.logException("e", "Unable to extract the backup due to corruption of compressed file(s).")
return False
Logger.log("d", "Removing current data in location: %s", target_path)
Resources.factoryReset()
Logger.log("d", "Extracting backup to location: %s", target_path)

View File

@ -10,7 +10,7 @@ import os.path
import uuid
import json
import locale
from typing import cast
from typing import cast, Any
try:
from sentry_sdk.hub import Hub
@ -32,6 +32,8 @@ from UM.Resources import Resources
from cura import ApplicationMetadata
catalog = i18nCatalog("cura")
home_dir = os.path.expanduser("~")
MYPY = False
if MYPY:
@ -83,6 +85,21 @@ class CrashHandler:
self.dialog = QDialog()
self._createDialog()
@staticmethod
def pruneSensitiveData(obj: Any) -> Any:
if isinstance(obj, str):
return obj.replace(home_dir, "<user_home>")
if isinstance(obj, list):
return [CrashHandler.pruneSensitiveData(item) for item in obj]
if isinstance(obj, dict):
return {k: CrashHandler.pruneSensitiveData(v) for k, v in obj.items()}
return obj
@staticmethod
def sentryBeforeSend(event, hint):
return CrashHandler.pruneSensitiveData(event)
def _createEarlyCrashDialog(self):
dialog = QDialog()
dialog.setMinimumWidth(500)
@ -161,7 +178,6 @@ class CrashHandler:
layout.addWidget(self._informationWidget())
layout.addWidget(self._exceptionInfoWidget())
layout.addWidget(self._logInfoWidget())
layout.addWidget(self._userDescriptionWidget())
layout.addWidget(self._buttonsWidget())
def _close(self):
@ -374,21 +390,6 @@ class CrashHandler:
return group
def _userDescriptionWidget(self):
group = QGroupBox()
group.setTitle(catalog.i18nc("@title:groupbox", "User description" +
" (Note: Developers may not speak your language, please use English if possible)"))
layout = QVBoxLayout()
# When sending the report, the user comments will be collected
self.user_description_text_area = QTextEdit()
self.user_description_text_area.setFocus(True)
layout.addWidget(self.user_description_text_area)
group.setLayout(layout)
return group
def _buttonsWidget(self):
buttons = QDialogButtonBox()
buttons.addButton(QDialogButtonBox.Close)
@ -403,9 +404,6 @@ class CrashHandler:
return buttons
def _sendCrashReport(self):
# Before sending data, the user comments are stored
self.data["user_info"] = self.user_description_text_area.toPlainText()
if with_sentry_sdk:
try:
hub = Hub.current

View File

@ -191,8 +191,6 @@ class CuraApplication(QtApplication):
self._cura_formula_functions = None # type: Optional[CuraFormulaFunctions]
self._cura_package_manager = None
self._machine_action_manager = None # type: Optional[MachineActionManager.MachineActionManager]
self.empty_container = None # type: EmptyInstanceContainer
@ -350,6 +348,9 @@ class CuraApplication(QtApplication):
for dir_name in ["extruders", "machine_instances", "materials", "plugins", "quality", "quality_changes", "user", "variants", "intent"]:
Resources.addExpectedDirNameInData(dir_name)
app_root = os.path.abspath(os.path.join(os.path.dirname(sys.executable)))
Resources.addSearchPath(os.path.join(app_root, "share", "cura", "resources"))
Resources.addSearchPath(os.path.join(self._app_install_dir, "share", "cura", "resources"))
if not hasattr(sys, "frozen"):
resource_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "resources")
@ -393,6 +394,8 @@ class CuraApplication(QtApplication):
SettingFunction.registerOperator("extruderValues", self._cura_formula_functions.getValuesInAllExtruders)
SettingFunction.registerOperator("resolveOrValue", self._cura_formula_functions.getResolveOrValue)
SettingFunction.registerOperator("defaultExtruderPosition", self._cura_formula_functions.getDefaultExtruderPosition)
SettingFunction.registerOperator("valueFromContainer", self._cura_formula_functions.getValueFromContainerAtIndex)
SettingFunction.registerOperator("extruderValueFromContainer", self._cura_formula_functions.getValueFromContainerAtIndexInExtruder)
# Adds all resources and container related resources.
def __addAllResourcesAndContainerResources(self) -> None:
@ -632,6 +635,12 @@ class CuraApplication(QtApplication):
def showPreferences(self) -> None:
self.showPreferencesWindow.emit()
# This is called by drag-and-dropping curapackage files.
@pyqtSlot(QUrl)
def installPackageViaDragAndDrop(self, file_url: str) -> Optional[str]:
filename = QUrl(file_url).toLocalFile()
return self._package_manager.installPackage(filename)
@override(Application)
def getGlobalContainerStack(self) -> Optional["GlobalStack"]:
return self._global_container_stack

View File

@ -1,4 +1,4 @@
# Copyright (c) 2018 Ultimaker B.V.
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
CuraAppName = "@CURA_APP_NAME@"
@ -9,3 +9,4 @@ CuraDebugMode = True if "@_cura_debugmode@" == "ON" else False
CuraCloudAPIRoot = "@CURA_CLOUD_API_ROOT@"
CuraCloudAPIVersion = "@CURA_CLOUD_API_VERSION@"
CuraCloudAccountAPIRoot = "@CURA_CLOUD_ACCOUNT_API_ROOT@"
CuraMarketplaceRoot = "@CURA_MARKETPLACE_ROOT@"

View File

@ -114,7 +114,10 @@ class IntentModel(ListModel):
Logger.log("w", "Could not find the variant %s", active_variant_name)
continue
active_variant_node = machine_node.variants[active_variant_name]
active_material_node = active_variant_node.materials[extruder.material.getMetaDataEntry("base_file")]
active_material_node = active_variant_node.materials.get(extruder.material.getMetaDataEntry("base_file"))
if active_material_node is None:
Logger.log("w", "Could not find the material %s", extruder.material.getMetaDataEntry("base_file"))
continue
nodes.add(active_material_node)
return nodes

View File

@ -2,6 +2,7 @@
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, Qt
from typing import Set
import cura.CuraApplication
from UM.Logger import Logger
@ -23,7 +24,7 @@ class QualitySettingsModel(ListModel):
GLOBAL_STACK_POSITION = -1
def __init__(self, parent = None):
def __init__(self, parent = None) -> None:
super().__init__(parent = parent)
self.addRoleName(self.KeyRole, "key")
@ -38,7 +39,9 @@ class QualitySettingsModel(ListModel):
self._application = cura.CuraApplication.CuraApplication.getInstance()
self._application.getMachineManager().activeStackChanged.connect(self._update)
self._selected_position = self.GLOBAL_STACK_POSITION #Must be either GLOBAL_STACK_POSITION or an extruder position (0, 1, etc.)
# Must be either GLOBAL_STACK_POSITION or an extruder position (0, 1, etc.)
self._selected_position = self.GLOBAL_STACK_POSITION
self._selected_quality_item = None # The selected quality in the quality management page
self._i18n_catalog = None
@ -47,14 +50,14 @@ class QualitySettingsModel(ListModel):
selectedPositionChanged = pyqtSignal()
selectedQualityItemChanged = pyqtSignal()
def setSelectedPosition(self, selected_position):
def setSelectedPosition(self, selected_position: int) -> None:
if selected_position != self._selected_position:
self._selected_position = selected_position
self.selectedPositionChanged.emit()
self._update()
@pyqtProperty(int, fset = setSelectedPosition, notify = selectedPositionChanged)
def selectedPosition(self):
def selectedPosition(self) -> int:
return self._selected_position
def setSelectedQualityItem(self, selected_quality_item):
@ -67,7 +70,7 @@ class QualitySettingsModel(ListModel):
def selectedQualityItem(self):
return self._selected_quality_item
def _update(self):
def _update(self) -> None:
Logger.log("d", "Updating {model_class_name}.".format(model_class_name = self.__class__.__name__))
if not self._selected_quality_item:
@ -83,7 +86,7 @@ class QualitySettingsModel(ListModel):
quality_changes_group = self._selected_quality_item["quality_changes_group"]
quality_node = None
settings_keys = set()
settings_keys = set() # type: Set[str]
if quality_group:
if self._selected_position == self.GLOBAL_STACK_POSITION:
quality_node = quality_group.node_for_global

View File

@ -115,9 +115,10 @@ class AuthorizationHelpers:
)
@staticmethod
## Generate a 16-character verification code.
# \param code_length: How long should the code be?
def generateVerificationCode(code_length: int = 16) -> str:
## Generate a verification code of arbitrary length.
# \param code_length: How long should the code be? This should never be lower than 16, but it's probably better to
# leave it at 32
def generateVerificationCode(code_length: int = 32) -> str:
return "".join(random.choice("0123456789ABCDEF") for i in range(code_length))
@staticmethod

View File

@ -25,6 +25,8 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
self.authorization_callback = None # type: Optional[Callable[[AuthenticationResponse], None]]
self.verification_code = None # type: Optional[str]
self.state = None # type: Optional[str]
# CURA-6609: Some browser seems to issue a HEAD instead of GET request as the callback.
def do_HEAD(self) -> None:
self.do_GET()
@ -58,7 +60,14 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
# \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:
state = self._queryGet(query, "state")
if state != self.state:
token_response = AuthenticationResponse(
success = False,
err_message=catalog.i18nc("@message",
"The provided state is not correct.")
)
elif 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)

View File

@ -25,3 +25,6 @@ class AuthorizationRequestServer(HTTPServer):
## Set the verification code on the request handler.
def setVerificationCode(self, verification_code: str) -> None:
self.RequestHandlerClass.verification_code = verification_code # type: ignore
def setState(self, state: str) -> None:
self.RequestHandlerClass.state = state # type: ignore

View File

@ -153,13 +153,15 @@ class AuthorizationService:
verification_code = self._auth_helpers.generateVerificationCode()
challenge_code = self._auth_helpers.generateVerificationCodeChallenge(verification_code)
state = AuthorizationHelpers.generateVerificationCode()
# 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.)",
"state": state, # Forever in our Hearts, RIP "(.Y.)" (2018-2020)
"code_challenge": challenge_code,
"code_challenge_method": "S512"
})
@ -168,7 +170,7 @@ class AuthorizationService:
QDesktopServices.openUrl(QUrl("{}?{}".format(self._auth_url, query_string)))
# Start a local web server to receive the callback URL on.
self._server.start(verification_code)
self._server.start(verification_code, state)
## Callback method for the authentication flow.
def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:

View File

@ -1,13 +1,18 @@
# Copyright (c) 2019 Ultimaker B.V.
# Copyright (c) 2020 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 typing import Any, Callable, Optional, TYPE_CHECKING
from UM.Logger import Logger
from cura.OAuth2.AuthorizationRequestServer import AuthorizationRequestServer
from cura.OAuth2.AuthorizationRequestHandler import AuthorizationRequestHandler
got_server_type = False
try:
from cura.OAuth2.AuthorizationRequestServer import AuthorizationRequestServer
from cura.OAuth2.AuthorizationRequestHandler import AuthorizationRequestHandler
got_server_type = True
except PermissionError: # Bug in http.server: Can't access MIME types. This will prevent the user from logging in. See Sentry bug Cura-3Q.
Logger.error("Can't start a server due to a PermissionError when starting the http.server.")
if TYPE_CHECKING:
from cura.OAuth2.Models import AuthenticationResponse
@ -36,7 +41,8 @@ class LocalAuthorizationServer:
## 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:
# \param state The unique state code (to ensure that the request we get back is really from the server.
def start(self, verification_code: str, state: str) -> None:
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.
@ -49,14 +55,16 @@ class LocalAuthorizationServer:
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)
if got_server_type:
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)
self._web_server.setState(state)
# Start the server on a new thread.
self._web_server_thread = threading.Thread(None, self._web_server.serve_forever, daemon = self._daemon)
self._web_server_thread.start()
# 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:

View File

@ -36,8 +36,10 @@ class ConvexHullDecorator(SceneNodeDecorator):
# Make sure the timer is created on the main thread
self._recompute_convex_hull_timer = None # type: Optional[QTimer]
self._timer_scheduled_to_be_created = False
from cura.CuraApplication import CuraApplication
if CuraApplication.getInstance() is not None:
self._timer_scheduled_to_be_created = True
CuraApplication.getInstance().callLater(self.createRecomputeConvexHullTimer)
self._raft_thickness = 0.0
@ -171,7 +173,12 @@ class ConvexHullDecorator(SceneNodeDecorator):
if self._recompute_convex_hull_timer is not None:
self._recompute_convex_hull_timer.start()
else:
self.recomputeConvexHull()
from cura.CuraApplication import CuraApplication
if not self._timer_scheduled_to_be_created:
# The timer is not created and we never scheduled it. Time to create it now!
CuraApplication.getInstance().callLater(self.createRecomputeConvexHullTimer)
# Now we know for sure that the timer has been scheduled for creation, so we can try this again.
CuraApplication.getInstance().callLater(self.recomputeConvexHullDelayed)
def recomputeConvexHull(self) -> None:
controller = Application.getInstance().getController()

View File

@ -133,6 +133,38 @@ class CuraFormulaFunctions:
context = self.createContextForDefaultValueEvaluation(global_stack)
return self.getResolveOrValue(property_key, context = context)
# Gets the value for the given setting key starting from the given container index.
def getValueFromContainerAtIndex(self, property_key: str, container_index: int,
context: Optional["PropertyEvaluationContext"] = None) -> Any:
machine_manager = self._application.getMachineManager()
global_stack = machine_manager.activeMachine
context = self.createContextForDefaultValueEvaluation(global_stack)
context.context["evaluate_from_container_index"] = container_index
return global_stack.getProperty(property_key, "value", context = context)
# Gets the extruder value for the given setting key starting from the given container index.
def getValueFromContainerAtIndexInExtruder(self, extruder_position: int, property_key: str, container_index: int,
context: Optional["PropertyEvaluationContext"] = None) -> Any:
machine_manager = self._application.getMachineManager()
global_stack = machine_manager.activeMachine
if extruder_position == -1:
extruder_position = int(machine_manager.defaultExtruderPosition)
global_stack = machine_manager.activeMachine
try:
extruder_stack = global_stack.extruderList[int(extruder_position)]
except IndexError:
Logger.log("w", "Value for %s of extruder %s was requested, but that extruder is not available. " % (property_key, extruder_position))
return None
context = self.createContextForDefaultValueEvaluation(extruder_stack)
context.context["evaluate_from_container_index"] = container_index
return self.getValueInExtruder(extruder_position, property_key, context)
# Creates a context for evaluating default values (skip the user_changes container).
def createContextForDefaultValueEvaluation(self, source_stack: "CuraContainerStack") -> "PropertyEvaluationContext":
context = PropertyEvaluationContext(source_stack)

View File

@ -58,7 +58,10 @@ class CuraStackBuilder:
# Create ExtruderStacks
extruder_dict = machine_definition.getMetaDataEntry("machine_extruder_trains")
for position in extruder_dict:
cls.createExtruderStackWithDefaultSetup(new_global_stack, position)
try:
cls.createExtruderStackWithDefaultSetup(new_global_stack, position)
except IndexError:
return None
for new_extruder in new_global_stack.extruders.values(): # Only register the extruders if we're sure that all of them are correct.
registry.addContainer(new_extruder)

View File

@ -320,8 +320,19 @@ class MachineManager(QObject):
# This signal might not have been emitted yet (if it didn't change) but we still want the models to update that depend on it because we changed the contents of the containers too.
extruder_manager.activeExtruderChanged.emit()
# Validate if the machine has the correct variants
# It can happen that a variant is empty, even though the machine has variants. This will ensure that that
# that situation will be fixed (and not occur again, since it switches it out to the preferred variant instead!)
machine_node = ContainerTree.getInstance().machines[global_stack.definition.getId()]
for extruder in self._global_container_stack.extruderList:
variant_name = extruder.variant.getName()
variant_node = machine_node.variants.get(variant_name)
if variant_node is None:
Logger.log("w", "An extruder has an unknown variant, switching it to the preferred variant")
self.setVariantByName(extruder.getMetaDataEntry("position"), machine_node.preferred_variant_name)
self.__emitChangedSignals()
## Given a definition id, return the machine with this id.
# Optional: add a list of keys and values to filter the list of machines with the given definition id
# \param definition_id \type{str} definition id that needs to look for
@ -336,9 +347,9 @@ class MachineManager(QObject):
return cast(GlobalStack, machine)
return None
@pyqtSlot(str)
@pyqtSlot(str, str)
def addMachine(self, definition_id: str, name: Optional[str] = None) -> None:
@pyqtSlot(str, result=bool)
@pyqtSlot(str, str, result = bool)
def addMachine(self, definition_id: str, name: Optional[str] = None) -> bool:
Logger.log("i", "Trying to add a machine with the definition id [%s]", definition_id)
if name is None:
definitions = CuraContainerRegistry.getInstance().findDefinitionContainers(id = definition_id)
@ -353,6 +364,8 @@ class MachineManager(QObject):
self.setActiveMachine(new_stack.getId())
else:
Logger.log("w", "Failed creating a new machine!")
return False
return True
def _checkStacksHaveErrors(self) -> bool:
time_start = time.time()
@ -747,6 +760,11 @@ class MachineManager(QObject):
result = [] # type: List[str]
for setting_instance in container.findInstances():
setting_key = setting_instance.definition.key
if setting_key == "print_sequence":
old_value = container.getProperty(setting_key, "value")
Logger.log("d", "Reset setting [%s] in [%s] because its old value [%s] is no longer valid", setting_key, container, old_value)
result.append(setting_key)
continue
if not self._global_container_stack.getProperty(setting_key, "type") in ("extruder", "optional_extruder"):
continue
@ -795,7 +813,7 @@ class MachineManager(QObject):
definition_changes_container.setProperty("machine_extruder_count", "value", extruder_count)
self.updateDefaultExtruder()
self.updateNumberExtrudersEnabled()
self.numberExtrudersEnabledChanged.emit()
self.correctExtruderSettings()
# Check to see if any objects are set to print with an extruder that will no longer exist
@ -1502,7 +1520,17 @@ class MachineManager(QObject):
if quality_id == empty_quality_container.getId():
extruder.intent = empty_intent_container
continue
quality_node = container_tree.machines[definition_id].variants[variant_name].materials[material_base_file].qualities[quality_id]
# Yes, we can find this in a single line of code. This makes it easier to read and it has the benefit
# that it doesn't lump key errors together for the crashlogs
try:
machine_node = container_tree.machines[definition_id]
variant_node = machine_node.variants[variant_name]
material_node = variant_node.materials[material_base_file]
quality_node = material_node.qualities[quality_id]
except KeyError as e:
Logger.error("Can't set the intent category '{category}' since the profile '{profile}' in the stack is not supported according to the container tree.".format(category = intent_category, profile = e))
continue
for intent_node in quality_node.intents.values():
if intent_node.intent_category == intent_category: # Found an intent with the correct category.

View File

@ -28,7 +28,11 @@ class TextManager(QObject):
def _loadChangeLogText(self) -> str:
# Load change log texts and organize them with a dict
file_path = Resources.getPath(Resources.Texts, "change_log.txt")
try:
file_path = Resources.getPath(Resources.Texts, "change_log.txt")
except FileNotFoundError:
# I have no idea how / when this happens, but we're getting crash reports about it.
return ""
change_logs_dict = {} # type: Dict[Version, Dict[str, List[str]]]
with open(file_path, "r", encoding = "utf-8") as f:
open_version = None # type: Optional[Version]

View File

@ -1,16 +1,32 @@
#!/usr/bin/env python3
# Copyright (c) 2019 Ultimaker B.V.
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
# Remove the working directory from sys.path.
# This fixes a security issue where Cura could import Python packages from the
# current working directory, and therefore be made to execute locally installed
# code (e.g. in the user's home directory where AppImages by default run from).
# See issue CURA-7081.
import sys
if "" in sys.path:
sys.path.remove("")
import argparse
import faulthandler
import os
import sys
# Workaround for a race condition on certain systems where there
# is a race condition between Arcus and PyQt. Importing Arcus
# first seems to prevent Sip from going into a state where it
# tries to create PyQt objects on a non-main thread.
import Arcus # @UnusedImport
import Savitar # @UnusedImport
from UM.Platform import Platform
from cura import ApplicationMetadata
from cura.ApplicationMetadata import CuraAppName
from cura.CrashHandler import CrashHandler
try:
import sentry_sdk
@ -29,21 +45,30 @@ parser.add_argument("--debug",
known_args = vars(parser.parse_known_args()[0])
if with_sentry_sdk:
sentry_env = "production"
sentry_env = "unknown" # Start off with a "IDK"
if hasattr(sys, "frozen"):
sentry_env = "production" # A frozen build has the posibility to be a "real" distribution.
if ApplicationMetadata.CuraVersion == "master":
sentry_env = "development"
sentry_env = "development" # Master is always a development version.
elif ApplicationMetadata.CuraVersion in ["beta", "BETA"]:
sentry_env = "beta"
try:
if ApplicationMetadata.CuraVersion.split(".")[2] == "99":
sentry_env = "nightly"
except IndexError:
pass
# Errors to be ignored by Sentry
ignore_errors = [KeyboardInterrupt, MemoryError]
sentry_sdk.init("https://5034bf0054fb4b889f82896326e79b13@sentry.io/1821564",
before_send = CrashHandler.sentryBeforeSend,
environment = sentry_env,
release = "cura%s" % ApplicationMetadata.CuraVersion,
default_integrations = False,
max_breadcrumbs = 300,
server_name = "cura")
server_name = "cura",
ignore_errors = ignore_errors)
if not known_args["debug"]:
def get_cura_dir_path():
@ -156,17 +181,11 @@ def exceptHook(hook_type, value, traceback):
# Set exception hook to use the crash dialog handler
sys.excepthook = exceptHook
# Enable dumping traceback for all threads
if sys.stderr:
if sys.stderr and not sys.stderr.closed:
faulthandler.enable(file = sys.stderr, all_threads = True)
else:
elif sys.stdout and not sys.stdout.closed:
faulthandler.enable(file = sys.stdout, all_threads = True)
# Workaround for a race condition on certain systems where there
# is a race condition between Arcus and PyQt. Importing Arcus
# first seems to prevent Sip from going into a state where it
# tries to create PyQt objects on a non-main thread.
import Arcus #@UnusedImport
import Savitar #@UnusedImport
from cura.CuraApplication import CuraApplication

View File

@ -13,6 +13,8 @@ export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}"
cd "${PROJECT_DIR}"
#
# Clone Uranium and set PYTHONPATH first
#

View File

@ -86,7 +86,7 @@ class ThreeMFReader(MeshReader):
## Convenience function that converts a SceneNode object (as obtained from libSavitar) to a scene node.
# \returns Scene node.
def _convertSavitarNodeToUMNode(self, savitar_node: Savitar.SceneNode) -> Optional[SceneNode]:
def _convertSavitarNodeToUMNode(self, savitar_node: Savitar.SceneNode, file_name: str = "") -> Optional[SceneNode]:
self._object_count += 1
node_name = "Object %s" % self._object_count
@ -104,6 +104,10 @@ class ThreeMFReader(MeshReader):
vertices = numpy.resize(data, (int(data.size / 3), 3))
mesh_builder.setVertices(vertices)
mesh_builder.calculateNormals(fast=True)
if file_name:
# The filename is used to give the user the option to reload the file if it is changed on disk
# It is only set for the root node of the 3mf file
mesh_builder.setFileName(file_name)
mesh_data = mesh_builder.build()
if len(mesh_data.getVertices()):
@ -171,7 +175,7 @@ class ThreeMFReader(MeshReader):
scene_3mf = parser.parse(archive.open("3D/3dmodel.model").read())
self._unit = scene_3mf.getUnit()
for node in scene_3mf.getSceneNodes():
um_node = self._convertSavitarNodeToUMNode(node)
um_node = self._convertSavitarNodeToUMNode(node, file_name)
if um_node is None:
continue
# compensate for original center position, if object(s) is/are not around its zero position

View File

@ -4,7 +4,8 @@
from configparser import ConfigParser
import zipfile
import os
from typing import cast, Dict, List, Optional, Tuple
import json
from typing import cast, Dict, List, Optional, Tuple, Any
import xml.etree.ElementTree as ET
@ -732,7 +733,25 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
base_file_name = os.path.basename(file_name)
self.setWorkspaceName(base_file_name)
return nodes
return nodes, self._loadMetadata(file_name)
@staticmethod
def _loadMetadata(file_name: str) -> Dict[str, Dict[str, Any]]:
archive = zipfile.ZipFile(file_name, "r")
metadata_files = [name for name in archive.namelist() if name.endswith("plugin_metadata.json")]
result = dict()
for metadata_file in metadata_files:
try:
plugin_id = metadata_file.split("/")[0]
result[plugin_id] = json.loads(archive.open("%s/plugin_metadata.json" % plugin_id).read().decode("utf-8"))
except Exception:
Logger.logException("w", "Unable to retrieve metadata for %s", metadata_file)
return result
def _processQualityChanges(self, global_stack):
if self._machine_info.quality_changes_info is None:

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for reading 3MF files.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -73,11 +73,25 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
version_config_parser.write(version_file_string)
archive.writestr(version_file, version_file_string.getvalue())
self._writePluginMetadataToArchive(archive)
# Close the archive & reset states.
archive.close()
mesh_writer.setStoreArchive(False)
return True
@staticmethod
def _writePluginMetadataToArchive(archive: zipfile.ZipFile) -> None:
file_name_template = "%s/plugin_metadata.json"
for plugin_id, metadata in Application.getInstance().getWorkspaceMetadataStorage().getAllData().items():
file_name = file_name_template % plugin_id
file_in_archive = zipfile.ZipInfo(file_name)
# We have to set the compress type of each file as well (it doesn't keep the type of the entire archive)
file_in_archive.compress_type = zipfile.ZIP_DEFLATED
import json
archive.writestr(file_in_archive, json.dumps(metadata, separators = (", ", ": "), indent = 4, skipkeys = True))
## Helper function that writes ContainerStacks, InstanceContainers and DefinitionContainers to the archive.
# \param container That follows the \type{ContainerInterface} to archive.
# \param archive The archive to write to.

View File

@ -1,5 +1,6 @@
# Copyright (c) 2015 Ultimaker B.V.
# Uranium is released under the terms of the LGPLv3 or higher.
from typing import Optional
from UM.Mesh.MeshWriter import MeshWriter
from UM.Math.Vector import Vector
@ -40,7 +41,7 @@ class ThreeMFWriter(MeshWriter):
}
self._unit_matrix_string = self._convertMatrixToString(Matrix())
self._archive = None
self._archive = None # type: Optional[zipfile.ZipFile]
self._store_archive = False
def _convertMatrixToString(self, matrix):

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for writing 3MF files.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -118,7 +118,7 @@ class AMFReader(MeshReader):
mesh.merge_vertices()
mesh.remove_unreferenced_vertices()
mesh.fix_normals()
mesh_data = self._toMeshData(mesh)
mesh_data = self._toMeshData(mesh, file_name)
new_node = CuraSceneNode()
new_node.setSelectable(True)
@ -147,7 +147,13 @@ class AMFReader(MeshReader):
return group_node
def _toMeshData(self, tri_node: trimesh.base.Trimesh) -> MeshData:
## Converts a Trimesh to Uranium's MeshData.
# \param tri_node A Trimesh containing the contents of a file that was
# just read.
# \param file_name The full original filename used to watch for changes
# \return Mesh data from the Trimesh in a way that Uranium can understand
# it.
def _toMeshData(self, tri_node: trimesh.base.Trimesh, file_name: str = "") -> MeshData:
tri_faces = tri_node.faces
tri_vertices = tri_node.vertices
@ -169,5 +175,5 @@ class AMFReader(MeshReader):
indices = numpy.asarray(indices, dtype = numpy.int32)
normals = calculateNormalsFromIndexedVertices(vertices, indices, face_count)
mesh_data = MeshData(vertices = vertices, indices = indices, normals = normals)
mesh_data = MeshData(vertices = vertices, indices = indices, normals = normals,file_name = file_name)
return mesh_data

View File

@ -3,5 +3,5 @@
"author": "fieldOfView",
"version": "1.0.0",
"description": "Provides support for reading AMF files.",
"api": "7.0.0"
"api": "7.1.0"
}

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"description": "Backup and restore your configuration.",
"version": "1.2.0",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -55,10 +55,22 @@ class CuraEngineBackend(QObject, Backend):
if Platform.isWindows():
executable_name += ".exe"
default_engine_location = executable_name
if os.path.exists(os.path.join(CuraApplication.getInstallPrefix(), "bin", executable_name)):
default_engine_location = os.path.join(CuraApplication.getInstallPrefix(), "bin", executable_name)
if hasattr(sys, "frozen"):
default_engine_location = os.path.join(os.path.dirname(os.path.abspath(sys.executable)), executable_name)
search_path = [
os.path.abspath(os.path.dirname(sys.executable)),
os.path.abspath(os.path.join(os.path.dirname(sys.executable), "bin")),
os.path.abspath(os.path.join(os.path.dirname(sys.executable), "..")),
os.path.join(CuraApplication.getInstallPrefix(), "bin"),
os.path.dirname(os.path.abspath(sys.executable)),
]
for path in search_path:
engine_path = os.path.join(path, executable_name)
if os.path.isfile(engine_path):
default_engine_location = engine_path
break
if Platform.isLinux() and not default_engine_location:
if not os.getenv("PATH"):
raise OSError("There is something wrong with your Linux installation.")
@ -409,7 +421,10 @@ class CuraEngineBackend(QObject, Backend):
if job.getResult() == StartJobResult.NothingToSlice:
if self._application.platformActivity:
self._error_message = Message(catalog.i18nc("@info:status", "Nothing to slice because none of the models fit the build volume or are assigned to a disabled extruder. Please scale or rotate models to fit, or enable an extruder."),
self._error_message = Message(catalog.i18nc("@info:status", "Please review settings and check if your models:"
"\n- Fit within the build volume"
"\n- Are assigned to an enabled extruder"
"\n- Are not all set as modifier meshes"),
title = catalog.i18nc("@info:title", "Unable to slice"))
self._error_message.show()
self.setState(BackendState.Error)

View File

@ -422,13 +422,14 @@ class StartSliceJob(Job):
# Pre-compute material material_bed_temp_prepend and material_print_temp_prepend
start_gcode = settings["machine_start_gcode"]
# Remove all the comments from the start g-code
start_gcode = re.sub(r";.+?(\n|$)", "\n", start_gcode)
bed_temperature_settings = ["material_bed_temperature", "material_bed_temperature_layer_0"]
pattern = r"\{(%s)(,\s?\w+)?\}" % "|".join(bed_temperature_settings) # match {setting} as well as {setting, extruder_nr}
settings["material_bed_temp_prepend"] = re.search(pattern, start_gcode) == None
print_temperature_settings = ["material_print_temperature", "material_print_temperature_layer_0", "default_material_print_temperature", "material_initial_print_temperature", "material_final_print_temperature", "material_standby_temperature"]
pattern = r"\{(%s)(,\s?\w+)?\}" % "|".join(print_temperature_settings) # match {setting} as well as {setting, extruder_nr}
settings["material_print_temp_prepend"] = re.search(pattern, start_gcode) == None
# Replace the setting tokens in start and end g-code.
# Use values from the first used extruder by default so we get the expected temperatures
initial_extruder_stack = CuraApplication.getInstance().getExtruderManager().getUsedExtruderStacks()[0]

View File

@ -2,7 +2,7 @@
"name": "CuraEngine Backend",
"author": "Ultimaker B.V.",
"description": "Provides the link to the CuraEngine slicing backend.",
"api": "7.0",
"api": "7.1",
"version": "1.0.1",
"i18n-catalog": "cura"
}

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for importing Cura profiles.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for exporting Cura profiles.",
"api": "7.0",
"api": "7.1",
"i18n-catalog":"cura"
}

View File

@ -44,6 +44,7 @@ class FirmwareUpdateCheckerJob(Job):
try:
# CURA-6698 Create an SSL context and use certifi CA certificates for verification.
context = ssl.SSLContext(protocol = ssl.PROTOCOL_TLSv1_2)
context.verify_mode = ssl.CERT_REQUIRED
context.load_verify_locations(cafile = certifi.where())
request = urllib.request.Request(url, headers = self._headers)

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Checks for firmware updates.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a machine actions for updating firmware.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Reads g-code from a compressed archive.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Writes g-code to a compressed archive.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for importing profiles from g-code files.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -1,4 +1,4 @@
# Copyright (c) 2018 Ultimaker B.V.
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import math
@ -169,6 +169,9 @@ class FlavorParser:
# A threshold is set to avoid weird paths in the GCode
if line_width > 1.2:
return 0.35
# Prevent showing infinitely wide lines
if line_width < 0.0:
return 0.0
return line_width
def _gCode0(self, position: Position, params: PositionOptional, path: List[List[Union[float, int]]]) -> Position:
@ -235,7 +238,7 @@ class FlavorParser:
def _gCode92(self, position: Position, params: PositionOptional, path: List[List[Union[float, int]]]) -> Position:
if params.e is not None:
# Sometimes a G92 E0 is introduced in the middle of the GCode so we need to keep those offsets for calculate the line_width
self._extrusion_length_offset[self._extruder_number] += position.e[self._extruder_number] - params.e
self._extrusion_length_offset[self._extruder_number] = position.e[self._extruder_number] - params.e
position.e[self._extruder_number] = params.e
self._previous_extrusion_value = params.e
else:
@ -258,16 +261,19 @@ class FlavorParser:
continue
if item.startswith(";"):
continue
if item[0] == "X":
x = float(item[1:])
if item[0] == "Y":
y = float(item[1:])
if item[0] == "Z":
z = float(item[1:])
if item[0] == "F":
f = float(item[1:]) / 60
if item[0] == "E":
e = float(item[1:])
try:
if item[0] == "X":
x = float(item[1:])
elif item[0] == "Y":
y = float(item[1:])
elif item[0] == "Z":
z = float(item[1:])
elif item[0] == "F":
f = float(item[1:]) / 60
elif item[0] == "E":
e = float(item[1:])
except ValueError: # Improperly formatted g-code: Coordinates are not floats.
continue # Skip the command then.
params = PositionOptional(x, y, z, f, e)
return func(position, params, path)
return position

View File

@ -1,8 +1,8 @@
{
"name": "G-code Reader",
"author": "Victor Larchenko, Ultimaker",
"author": "Victor Larchenko, Ultimaker B.V.",
"version": "1.0.1",
"description": "Allows loading and displaying G-code files.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Writes g-code to a file.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -1,4 +1,4 @@
# Copyright (c) 2015 Ultimaker B.V.
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import numpy
@ -96,7 +96,7 @@ class ImageReader(MeshReader):
texel_width = 1.0 / (width_minus_one) * scale_vector.x
texel_height = 1.0 / (height_minus_one) * scale_vector.z
height_data = numpy.zeros((height, width), dtype=numpy.float32)
height_data = numpy.zeros((height, width), dtype = numpy.float32)
for x in range(0, width):
for y in range(0, height):
@ -112,7 +112,7 @@ class ImageReader(MeshReader):
height_data = 1 - height_data
for _ in range(0, blur_iterations):
copy = numpy.pad(height_data, ((1, 1), (1, 1)), mode= "edge")
copy = numpy.pad(height_data, ((1, 1), (1, 1)), mode = "edge")
height_data += copy[1:-1, 2:]
height_data += copy[1:-1, :-2]
@ -165,7 +165,7 @@ class ImageReader(MeshReader):
offsetsz = numpy.array(offsetsz, numpy.float32).reshape(-1, 1) * texel_height
# offsets for each texel quad
heightmap_vertex_offsets = numpy.concatenate([offsetsx, numpy.zeros((offsetsx.shape[0], offsetsx.shape[1]), dtype=numpy.float32), offsetsz], 1)
heightmap_vertex_offsets = numpy.concatenate([offsetsx, numpy.zeros((offsetsx.shape[0], offsetsx.shape[1]), dtype = numpy.float32), offsetsz], 1)
heightmap_vertices += heightmap_vertex_offsets.repeat(6, 0).reshape(-1, 6, 3)
# apply height data to y values
@ -174,7 +174,7 @@ class ImageReader(MeshReader):
heightmap_vertices[:, 2, 1] = heightmap_vertices[:, 3, 1] = height_data[1:, 1:].reshape(-1)
heightmap_vertices[:, 4, 1] = height_data[:-1, 1:].reshape(-1)
heightmap_indices = numpy.array(numpy.mgrid[0:heightmap_face_count * 3], dtype=numpy.int32).reshape(-1, 3)
heightmap_indices = numpy.array(numpy.mgrid[0:heightmap_face_count * 3], dtype = numpy.int32).reshape(-1, 3)
mesh._vertices[0:(heightmap_vertices.size // 3), :] = heightmap_vertices.reshape(-1, 3)
mesh._indices[0:(heightmap_indices.size // 3), :] = heightmap_indices
@ -223,7 +223,7 @@ class ImageReader(MeshReader):
mesh.addFaceByPoints(geo_width, 0, y, geo_width, 0, ny, geo_width, he1, ny)
mesh.addFaceByPoints(geo_width, he1, ny, geo_width, he0, y, geo_width, 0, y)
mesh.calculateNormals(fast=True)
mesh.calculateNormals(fast = True)
scene_node.setMeshData(mesh.build())

View File

@ -1,4 +1,4 @@
# Copyright (c) 2015 Ultimaker B.V.
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import os
@ -33,9 +33,9 @@ class ImageReaderUI(QObject):
self.base_height = 0.4
self.peak_height = 2.5
self.smoothing = 1
self.lighter_is_higher = False;
self.use_transparency_model = True;
self.transmittance_1mm = 50.0; # based on pearl PLA
self.lighter_is_higher = False
self.use_transparency_model = True
self.transmittance_1mm = 50.0 # based on pearl PLA
self._ui_lock = threading.Lock()
self._cancelled = False
@ -85,7 +85,7 @@ class ImageReaderUI(QObject):
Logger.log("d", "Creating ImageReader config UI")
path = os.path.join(PluginRegistry.getInstance().getPluginPath("ImageReader"), "ConfigUI.qml")
self._ui_view = Application.getInstance().createQmlComponent(path, {"manager": self})
self._ui_view.setFlags(self._ui_view.flags() & ~Qt.WindowCloseButtonHint & ~Qt.WindowMinimizeButtonHint & ~Qt.WindowMaximizeButtonHint);
self._ui_view.setFlags(self._ui_view.flags() & ~Qt.WindowCloseButtonHint & ~Qt.WindowMinimizeButtonHint & ~Qt.WindowMaximizeButtonHint)
self._disable_size_callbacks = False
@pyqtSlot()

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Enables ability to generate printable geometry from 2D image files.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for importing profiles from legacy Cura versions.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -1,8 +1,8 @@
{
"name": "Machine Settings action",
"author": "fieldOfView",
"name": "Machine Settings Action",
"author": "fieldOfView, Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a way to change machine settings (such as build volume, nozzle size, etc.).",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -73,6 +73,8 @@ class ModelChecker(QObject, Extension):
# Check node material shrinkage and bounding box size
for node in self.sliceableNodes():
node_extruder_position = node.callDecoration("getActiveExtruderPosition")
if node_extruder_position is None:
continue
# This function can be triggered in the middle of a machine change, so do not proceed if the machine change
# has not done yet.

View File

@ -2,7 +2,7 @@
"name": "Model Checker",
"author": "Ultimaker B.V.",
"version": "1.0.1",
"api": "7.0",
"api": "7.1",
"description": "Checks models and print configuration for possible printing issues and give suggestions.",
"i18n-catalog": "cura"
}

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a monitor stage in Cura.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -1,10 +1,11 @@
# Copyright (c) 2016 Ultimaker B.V.
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty
from UM.FlameProfiler import pyqtSlot
from UM.Application import Application
from UM.PluginRegistry import PluginRegistry
from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.Settings.SettingInstance import SettingInstance
from UM.Logger import Logger
@ -24,6 +25,8 @@ class PerObjectSettingVisibilityHandler(UM.Settings.Models.SettingVisibilityHand
self._node = None
self._stack = None
PluginRegistry.getInstance().getPluginObject("PerObjectSettingsTool").visibility_handler = self
# this is a set of settings that will be skipped if the user chooses to reset.
self._skip_reset_setting_set = set()
@ -68,7 +71,7 @@ class PerObjectSettingVisibilityHandler(UM.Settings.Models.SettingVisibilityHand
# Add all instances that are not added, but are in visibility list
for item in visible:
if not settings.getInstance(item): # Setting was not added already.
if settings.getInstance(item) is None: # Setting was not added already.
definition = self._stack.getSettingDefinition(item)
if definition:
new_instance = SettingInstance(definition, settings)

View File

@ -49,18 +49,6 @@ Item
visibility_handler.addSkipResetSetting(currentMeshType)
}
function setOverhangsMeshType()
{
if (infillOnlyCheckbox.checked)
{
setMeshType(infillMeshType)
}
else
{
setMeshType(cuttingMeshType)
}
}
function setMeshType(type)
{
UM.ActiveTool.setProperty("MeshType", type)
@ -140,26 +128,43 @@ Item
verticalAlignment: Text.AlignVCenter
}
CheckBox
ComboBox
{
id: infillOnlyCheckbox
id: infillOnlyComboBox
width: parent.width / 2 - UM.Theme.getSize("default_margin").width
text: catalog.i18nc("@action:checkbox", "Infill only");
model: ListModel
{
id: infillOnlyComboBoxModel
style: UM.Theme.styles.checkbox;
Component.onCompleted: {
append({ text: catalog.i18nc("@item:inlistbox", "Infill mesh only") })
append({ text: catalog.i18nc("@item:inlistbox", "Cutting mesh") })
}
}
visible: currentMeshType === infillMeshType || currentMeshType === cuttingMeshType
onClicked: setOverhangsMeshType()
onActivated:
{
if (index == 0){
setMeshType(infillMeshType)
} else {
setMeshType(cuttingMeshType)
}
}
Binding
{
target: infillOnlyCheckbox
property: "checked"
value: currentMeshType === infillMeshType
target: infillOnlyComboBox
property: "currentIndex"
value: currentMeshType === infillMeshType ? 0 : 1
}
}
Column // Settings Dialog
Column // List of selected Settings to override for the selected object
{
// This is to ensure that the panel is first increasing in size up to 200 and then shows a scrollbar.
// It kinda looks ugly otherwise (big panel, no content on it)

View File

@ -1,5 +1,6 @@
# Copyright (c) 2016 Ultimaker B.V.
# Uranium is released under the terms of the LGPLv3 or higher.
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.Logger import Logger
from UM.Tool import Tool
from UM.Scene.Selection import Selection
@ -22,14 +23,13 @@ class PerObjectSettingsTool(Tool):
self._multi_extrusion = False
self._single_model_selected = False
self.visibility_handler = None
Selection.selectionChanged.connect(self.propertyChanged)
Application.getInstance().globalContainerStackChanged.connect(self._onGlobalContainerChanged)
self._onGlobalContainerChanged()
Selection.selectionChanged.connect(self._updateEnabled)
def event(self, event):
super().event(event)
if event.type == Event.MousePressEvent and self._controller.getToolsEnabled():
@ -68,7 +68,8 @@ class PerObjectSettingsTool(Tool):
## Returns True when the mesh_type was changed, False when current mesh_type == mesh_type
def setMeshType(self, mesh_type: str) -> bool:
if self.getMeshType() == mesh_type:
old_mesh_type = self.getMeshType()
if old_mesh_type == mesh_type:
return False
selected_object = Selection.getSelectedObject(0)
@ -81,6 +82,7 @@ class PerObjectSettingsTool(Tool):
selected_object.addDecorator(SettingOverrideDecorator())
stack = selected_object.callDecoration("getStack")
settings_visibility_changed = False
settings = stack.getTop()
for property_key in ["infill_mesh", "cutting_mesh", "support_mesh", "anti_overhang_mesh"]:
if property_key != mesh_type:
@ -94,6 +96,23 @@ class PerObjectSettingsTool(Tool):
new_instance.resetState() # Ensure that the state is not seen as a user state.
settings.addInstance(new_instance)
for property_key in ["top_bottom_thickness", "wall_thickness"]:
if mesh_type == "infill_mesh":
if settings.getInstance(property_key) is None:
definition = stack.getSettingDefinition(property_key)
new_instance = SettingInstance(definition, settings)
new_instance.setProperty("value", 0)
new_instance.resetState() # Ensure that the state is not seen as a user state.
settings.addInstance(new_instance)
settings_visibility_changed = True
elif old_mesh_type == "infill_mesh" and settings.getInstance(property_key) and settings.getProperty(property_key, "value") == 0:
settings.removeInstance(property_key)
settings_visibility_changed = True
if settings_visibility_changed:
self.visibility_handler.forceVisibilityChanged()
self.propertyChanged.emit()
return True

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides the Per Model Settings.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -484,15 +484,53 @@ UM.Dialog
onClicked: dialog.accept()
}
Cura.SecondaryButton
Item
{
objectName: "postProcessingSaveAreaButton"
visible: activeScriptsList.count > 0
height: UM.Theme.getSize("action_button").height
width: height
tooltip: catalog.i18nc("@info:tooltip", "Change active post-processing scripts")
onClicked: dialog.show()
iconSource: "postprocessing.svg"
fixedWidthMode: true
Cura.SecondaryButton
{
height: UM.Theme.getSize("action_button").height
tooltip:
{
var tipText = catalog.i18nc("@info:tooltip", "Change active post-processing scripts.");
if (activeScriptsList.count > 0)
{
tipText += "<br><br>" + catalog.i18ncp("@info:tooltip",
"The following script is active:",
"The following scripts are active:",
activeScriptsList.count
) + "<ul>";
for(var i = 0; i < activeScriptsList.count; i++)
{
tipText += "<li>" + manager.getScriptLabelByKey(manager.scriptList[i]) + "</li>";
}
tipText += "</ul>";
}
return tipText
}
toolTipContentAlignment: Cura.ToolTip.ContentAlignment.AlignLeft
onClicked: dialog.show()
iconSource: "postprocessing.svg"
fixedWidthMode: false
}
Cura.NotificationIcon
{
id: activeScriptCountIcon
visible: activeScriptsList.count > 0
anchors
{
top: parent.top
right: parent.right
rightMargin: (-0.5 * width) | 0
topMargin: (-0.5 * height) | 0
}
labelText: activeScriptsList.count
}
}
}

View File

@ -2,7 +2,7 @@
"name": "Post Processing",
"author": "Ultimaker",
"version": "2.2.1",
"api": "7.0",
"api": "7.1",
"description": "Extension that allows for user created scripts for post processing",
"catalog": "cura"
}

View File

@ -6,14 +6,22 @@
#Authors of the 2-1 ColorMix plug-in / script:
# Written by John Hryb - john.hryb.4@gmail.com
##history / change-log:
##V1.0.0
## Uses -
## M163 - Set Mix Factor
## M164 - Save Mix - saves to T3 as a unique mix
import re #To perform the search and replace.
#history / change-log:
#V1.0.0 - Initial
#V1.1.0 -
# additions:
#Object number - To select individual models or all when using "one at a time" print sequence
#V1.2.0
# fixed layer heights Cura starts at 1 while G-code starts at 0
# removed notes
# changed Units of measurement to Units
#V1.2.1
# Fixed mm bug when not in multiples of layer height
# Uses -
# M163 - Set Mix Factor
# M164 - Save Mix - saves to T2 as a unique mix
import re #To perform the search and replace.
from ..Script import Script
class ColorMix(Script):
@ -22,20 +30,28 @@ class ColorMix(Script):
def getSettingDataString(self):
return """{
"name":"ColorMix 2-1",
"name":"ColorMix 2-1 V1.2.1",
"key":"ColorMix 2-1",
"metadata": {},
"version": 2,
"settings":
{
"measurement_units":
"units_of_measurement":
{
"label": "Units of measurement",
"label": "Units",
"description": "Input value as mm or layer number.",
"type": "enum",
"options": {"mm":"mm","layer":"Layer"},
"default_value": "layer"
},
"object_number":
{
"label": "Object Number",
"description": "Select model to apply to for print one at a time print sequence. 0 = everything",
"type": "int",
"default_value": 0,
"minimum_value": "0"
},
"start_height":
{
"label": "Start Height",
@ -59,10 +75,10 @@ class ColorMix(Script):
"type": "float",
"default_value": 0,
"minimum_value": "0",
"minimum_value_warning": "0.1",
"enabled": "c_behavior == 'blend_value'"
"minimum_value_warning": "start_height",
"enabled": "behavior == 'blend_value'"
},
"mix_start_ratio":
"mix_start":
{
"label": "Start mix ratio",
"description": "First extruder percentage 0-100",
@ -72,7 +88,7 @@ class ColorMix(Script):
"minimum_value_warning": "0",
"maximum_value_warning": "100"
},
"mix_finish_ratio":
"mix_finish":
{
"label": "End mix ratio",
"description": "First extruder percentage 0-100 to finish blend",
@ -81,14 +97,7 @@ class ColorMix(Script):
"minimum_value": "0",
"minimum_value_warning": "0",
"maximum_value_warning": "100",
"enabled": "c_behavior == 'blend_value'"
},
"notes":
{
"label": "Notes",
"description": "A spot to put a note",
"type": "str",
"default_value": ""
"enabled": "behavior == 'blend_value'"
}
}
}"""
@ -112,52 +121,53 @@ class ColorMix(Script):
return default
def execute(self, data):
#get user variables
firstHeight = 0.0
secondHeight = 0.0
firstMix = 0.0
SecondMix = 0.0
firstHeight = self.getSettingValueByKey("start_height")
secondHeight = self.getSettingValueByKey("finish_height")
firstMix = self.getSettingValueByKey("mix_start_ratio")
SecondMix = self.getSettingValueByKey("mix_finish_ratio")
#locals
layer = 0
firstMix = self.getSettingValueByKey("mix_start")
secondMix = self.getSettingValueByKey("mix_finish")
modelOfInterest = self.getSettingValueByKey("object_number")
#get layer height
layerHeight = .2
layerHeight = 0
for active_layer in data:
lines = active_layer.split("\n")
for line in lines:
if ";Layer height: " in line:
layerHeight = self.getValue(line, ";Layer height: ", layerHeight)
break
if layerHeight != 0:
break
#default layerHeight if not found
if layerHeight == 0:
layerHeight = .2
#get layers to use
startLayer = 0
endLayer = 0
if self.getSettingValueByKey("measurement_units") == "mm":
if firstHeight == 0:
startLayer = 0
else:
startLayer = firstHeight / layerHeight
if secondHeight == 0:
endLayer = 0
else:
endLayer = secondHeight / layerHeight
else: #layer height
startLayer = firstHeight
endLayer = secondHeight
if self.getSettingValueByKey("units_of_measurement") == "mm":
startLayer = round(firstHeight / layerHeight)
endLayer = round(secondHeight / layerHeight)
else: #layer height shifts down by one for g-code
if firstHeight <= 0:
firstHeight = 1
if secondHeight <= 0:
secondHeight = 1
startLayer = firstHeight - 1
endLayer = secondHeight - 1
#see if one-shot
if self.getSettingValueByKey("behavior") == "fixed_value":
endLayer = startLayer
firstExtruderIncrements = 0
else: #blend
firstExtruderIncrements = (SecondMix - firstMix) / (endLayer - startLayer)
firstExtruderIncrements = (secondMix - firstMix) / (endLayer - startLayer)
firstExtruderValue = 0
index = 0
#start scanning
layer = -1
modelNumber = 0
for active_layer in data:
modified_gcode = ""
lineIndex = 0;
@ -169,22 +179,30 @@ class ColorMix(Script):
# find current layer
if ";LAYER:" in line:
layer = self.getValue(line, ";LAYER:", layer)
if (layer >= startLayer) and (layer <= endLayer): #find layers of interest
if lines[lineIndex + 4] == "T2": #check if needing to delete old data
del lines[(lineIndex + 1):(lineIndex + 5)]
firstExtruderValue = int(((layer - startLayer) * firstExtruderIncrements) + firstMix)
if firstExtruderValue == 100:
modified_gcode += "M163 S0 P1\n"
modified_gcode += "M163 S1 P0\n"
elif firstExtruderValue == 0:
modified_gcode += "M163 S0 P0\n"
modified_gcode += "M163 S1 P1\n"
else:
modified_gcode += "M163 S0 P0.{:02d}\n".format(firstExtruderValue)
modified_gcode += "M163 S1 P0.{:02d}\n".format(100 - firstExtruderValue)
modified_gcode += "M164 S2\n"
modified_gcode += "T2\n"
#get model number by layer 0 repeats
if layer == 0:
modelNumber = modelNumber + 1
#search for layers to manipulate
if (layer >= startLayer) and (layer <= endLayer):
#make sure correct model is selected
if (modelOfInterest == 0) or (modelOfInterest == modelNumber):
#Delete old data if required
if lines[lineIndex + 4] == "T2":
del lines[(lineIndex + 1):(lineIndex + 5)]
#add mixing commands
firstExtruderValue = int(((layer - startLayer) * firstExtruderIncrements) + firstMix)
if firstExtruderValue == 100:
modified_gcode += "M163 S0 P1\n"
modified_gcode += "M163 S1 P0\n"
elif firstExtruderValue == 0:
modified_gcode += "M163 S0 P0\n"
modified_gcode += "M163 S1 P1\n"
else:
modified_gcode += "M163 S0 P0.{:02d}\n".format(firstExtruderValue)
modified_gcode += "M163 S1 P0.{:02d}\n".format(100 - firstExtruderValue)
modified_gcode += "M164 S2\n"
modified_gcode += "T2\n"
lineIndex += 1 #for deleting index
data[index] = modified_gcode
index += 1
return data
return data

View File

@ -72,18 +72,25 @@ class DisplayFilenameAndLayerOnLCD(Script):
lcd_text = "M117 Printing " + name + " - Layer "
i = self.getSettingValueByKey("startNum")
for layer in data:
display_text = lcd_text + str(i) + " " + name
display_text = lcd_text + str(i)
layer_index = data.index(layer)
lines = layer.split("\n")
for line in lines:
if line.startswith(";LAYER_COUNT:"):
max_layer = line
max_layer = max_layer.split(":")[1]
if self.getSettingValueByKey("startNum") == 0:
max_layer = str(int(max_layer) - 1)
if line.startswith(";LAYER:"):
if self.getSettingValueByKey("maxlayer"):
display_text = display_text + " of " + max_layer
if not self.getSettingValueByKey("scroll"):
display_text = display_text + " " + name
else:
display_text = display_text + "!"
if not self.getSettingValueByKey("scroll"):
display_text = display_text + " " + name + "!"
else:
display_text = display_text + "!"
line_index = lines.index(line)
lines.insert(line_index + 1, display_text)
i += 1

View File

@ -2,6 +2,7 @@
from ..Script import Script
class TimeLapse(Script):
def __init__(self):
super().__init__()
@ -75,21 +76,29 @@ class TimeLapse(Script):
trigger_command = self.getSettingValueByKey("trigger_command")
pause_length = self.getSettingValueByKey("pause_length")
gcode_to_append = ";TimeLapse Begin\n"
last_x = 0
last_y = 0
if park_print_head:
gcode_to_append += self.putValue(G = 1, F = feed_rate, X = x_park, Y = y_park) + " ;Park print head\n"
gcode_to_append += self.putValue(M = 400) + " ;Wait for moves to finish\n"
gcode_to_append += self.putValue(G=1, F=feed_rate,
X=x_park, Y=y_park) + " ;Park print head\n"
gcode_to_append += self.putValue(M=400) + " ;Wait for moves to finish\n"
gcode_to_append += trigger_command + " ;Snap Photo\n"
gcode_to_append += self.putValue(G = 4, P = pause_length) + " ;Wait for camera\n"
gcode_to_append += ";TimeLapse End\n"
for layer in data:
gcode_to_append += self.putValue(G=4, P=pause_length) + " ;Wait for camera\n"
for idx, layer in enumerate(data):
for line in layer.split("\n"):
if self.getValue(line, "G") in {0, 1}: # Track X,Y location.
last_x = self.getValue(line, "X", last_x)
last_y = self.getValue(line, "Y", last_y)
# Check that a layer is being printed
lines = layer.split("\n")
for line in lines:
if ";LAYER:" in line:
index = data.index(layer)
layer += gcode_to_append
data[index] = layer
layer += "G0 X%s Y%s\n" % (last_x, last_y)
data[idx] = layer
break
return data

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a prepare stage in Cura.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a preview stage in Cura.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -47,7 +47,10 @@ class WindowsRemovableDrivePlugin(RemovableDrivePlugin.RemovableDrivePlugin):
def checkRemovableDrives(self):
drives = {}
# The currently available disk drives, e.g.: bitmask = ...1100 <-- ...DCBA
bitmask = ctypes.windll.kernel32.GetLogicalDrives()
# Since we are ignoring drives A and B, the bitmask has has to shift twice to the right
bitmask >>= 2
# Check possible drive letters, from C to Z
# Note: using ascii_uppercase because we do not want this to change with locale!
# Skip A and B, since those drives are typically reserved for floppy disks.

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"description": "Provides removable drive hotplugging and writing support.",
"version": "1.0.1",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -3,6 +3,9 @@
from UM.Logger import LogOutput
from typing import Set
from cura.CrashHandler import CrashHandler
try:
from sentry_sdk import add_breadcrumb
except ImportError:
@ -10,8 +13,6 @@ except ImportError:
from typing import Optional
import os
home_dir = os.path.expanduser("~")
class SentryLogger(LogOutput):
# Sentry (https://sentry.io) is the service that Cura uses for logging crashes. This logger ensures that the
@ -37,7 +38,7 @@ class SentryLogger(LogOutput):
# \param message String containing message to be logged
def log(self, log_type: str, message: str) -> None:
level = self._translateLogType(log_type)
message = self._pruneSensitiveData(message)
message = CrashHandler.pruneSensitiveData(message)
if level is None:
if message not in self._show_once:
level = self._translateLogType(log_type[0])
@ -47,12 +48,6 @@ class SentryLogger(LogOutput):
else:
add_breadcrumb(level = level, message = message)
@staticmethod
def _pruneSensitiveData(message):
if home_dir in message:
message = message.replace(home_dir, "<user_home>")
return message
@staticmethod
def _translateLogType(log_type: str) -> Optional[str]:
return SentryLogger._levels.get(log_type)

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.0",
"description": "Logs certain events so that they can be used by the crash reporter",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -46,10 +46,19 @@ class SimulationPass(RenderPass):
self._layer_view = layerview
self._compatibility_mode = layerview.getCompatibilityMode()
def _updateLayerShaderValues(self):
def render(self):
if not self._layer_shader:
if self._compatibility_mode:
shader_filename = "layers.shader"
shadow_shader_filename = "layers_shadow.shader"
else:
shader_filename = "layers3d.shader"
shadow_shader_filename = "layers3d_shadow.shader"
self._layer_shader = OpenGL.getInstance().createShaderProgram(os.path.join(PluginRegistry.getInstance().getPluginPath("SimulationView"), shader_filename))
self._layer_shadow_shader = OpenGL.getInstance().createShaderProgram(os.path.join(PluginRegistry.getInstance().getPluginPath("SimulationView"), shadow_shader_filename))
self._current_shader = self._layer_shader
# Use extruder 0 if the extruder manager reports extruder index -1 (for single extrusion printers)
self._layer_shader.setUniformValue("u_active_extruder",
float(max(0, self._extruder_manager.activeExtruderIndex)))
self._layer_shader.setUniformValue("u_active_extruder", float(max(0, self._extruder_manager.activeExtruderIndex)))
if self._layer_view:
self._layer_shader.setUniformValue("u_max_feedrate", self._layer_view.getMaxFeedrate())
self._layer_shader.setUniformValue("u_min_feedrate", self._layer_view.getMinFeedrate())
@ -62,7 +71,7 @@ class SimulationPass(RenderPass):
self._layer_shader.setUniformValue("u_show_skin", self._layer_view.getShowSkin())
self._layer_shader.setUniformValue("u_show_infill", self._layer_view.getShowInfill())
else:
# defaults
#defaults
self._layer_shader.setUniformValue("u_max_feedrate", 1)
self._layer_shader.setUniformValue("u_min_feedrate", 0)
self._layer_shader.setUniformValue("u_max_thickness", 1)
@ -74,20 +83,6 @@ class SimulationPass(RenderPass):
self._layer_shader.setUniformValue("u_show_skin", 1)
self._layer_shader.setUniformValue("u_show_infill", 1)
def render(self):
if not self._layer_shader:
if self._compatibility_mode:
shader_filename = "layers.shader"
shadow_shader_filename = "layers_shadow.shader"
else:
shader_filename = "layers3d.shader"
shadow_shader_filename = "layers3d_shadow.shader"
self._layer_shader = OpenGL.getInstance().createShaderProgram(os.path.join(PluginRegistry.getInstance().getPluginPath("SimulationView"), shader_filename))
self._layer_shadow_shader = OpenGL.getInstance().createShaderProgram(os.path.join(PluginRegistry.getInstance().getPluginPath("SimulationView"), shadow_shader_filename))
self._current_shader = self._layer_shader
self._updateLayerShaderValues()
if not self._tool_handle_shader:
self._tool_handle_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "toolhandle.shader"))
@ -102,6 +97,7 @@ class SimulationPass(RenderPass):
nozzle_node = None
for node in DepthFirstIterator(self._scene.getRoot()):
if isinstance(node, ToolHandle):
tool_handle_batch.addItem(node.getWorldTransformation(), mesh = node.getSolidMesh())
@ -116,24 +112,29 @@ class SimulationPass(RenderPass):
# Render all layers below a certain number as line mesh instead of vertices.
if self._layer_view._current_layer_num > -1 and ((not self._layer_view._only_show_top_layers) or (not self._layer_view.getCompatibilityMode())):
start = self._layer_view.start_elements_index
end = self._layer_view.end_elements_index
index = self._layer_view._current_path_num
offset = 0
layer = layer_data.getLayer(self._layer_view._current_layer_num)
if layer is None:
continue
for polygon in layer.polygons:
# The size indicates all values in the two-dimension array, and the second dimension is
# always size 3 because we have 3D points.
if index >= polygon.data.size // 3 - offset:
index -= polygon.data.size // 3 - offset
offset = 1 # This is to avoid the first point when there is more than one polygon, since has the same value as the last point in the previous polygon
continue
# The head position is calculated and translated
head_position = Vector(polygon.data[index + offset][0], polygon.data[index + offset][1],
polygon.data[index + offset][2]) + node.getWorldPosition()
break
start = 0
end = 0
element_counts = layer_data.getElementCounts()
for layer in sorted(element_counts.keys()):
# In the current layer, we show just the indicated paths
if layer == self._layer_view._current_layer_num:
# We look for the position of the head, searching the point of the current path
index = self._layer_view._current_path_num
offset = 0
for polygon in layer_data.getLayer(layer).polygons:
# The size indicates all values in the two-dimension array, and the second dimension is
# always size 3 because we have 3D points.
if index >= polygon.data.size // 3 - offset:
index -= polygon.data.size // 3 - offset
offset = 1 # This is to avoid the first point when there is more than one polygon, since has the same value as the last point in the previous polygon
continue
# The head position is calculated and translated
head_position = Vector(polygon.data[index+offset][0], polygon.data[index+offset][1], polygon.data[index+offset][2]) + node.getWorldPosition()
break
break
if self._layer_view._minimum_layer_num > layer:
start += element_counts[layer]
end += element_counts[layer]
# Calculate the range of paths in the last layer
current_layer_start = end

View File

@ -1,4 +1,4 @@
# Copyright (c) 2018 Ultimaker B.V.
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import sys
@ -56,6 +56,8 @@ class SimulationView(CuraView):
LAYER_VIEW_TYPE_FEEDRATE = 2
LAYER_VIEW_TYPE_THICKNESS = 3
_no_layers_warning_preference = "view/no_layers_warning"
def __init__(self, parent = None) -> None:
super().__init__(parent)
@ -71,8 +73,6 @@ class SimulationView(CuraView):
self._max_paths = 0
self._current_path_num = 0
self._minimum_path_num = 0
self.start_elements_index = 0
self.end_elements_index = 0
self.currentLayerNumChanged.connect(self._onCurrentLayerNumChanged)
self._busy = False
@ -116,8 +116,12 @@ class SimulationView(CuraView):
self._only_show_top_layers = bool(Application.getInstance().getPreferences().getValue("view/only_show_top_layers"))
self._compatibility_mode = self._evaluateCompatibilityMode()
self._wireprint_warning_message = Message(catalog.i18nc("@info:status", "Cura does not accurately display layers when Wire Printing is enabled"),
self._wireprint_warning_message = Message(catalog.i18nc("@info:status", "Cura does not accurately display layers when Wire Printing is enabled."),
title = catalog.i18nc("@info:title", "Simulation View"))
self._slice_first_warning_message = Message(catalog.i18nc("@info:status", "Nothing is shown because you need to slice first."), title = catalog.i18nc("@info:title", "No layers to show"),
option_text = catalog.i18nc("@info:option_text", "Do not show this message again"), option_state = False)
self._slice_first_warning_message.optionToggled.connect(self._onDontAskMeAgain)
CuraApplication.getInstance().getPreferences().addPreference(self._no_layers_warning_preference, True)
QtApplication.getInstance().engineCreatedSignal.connect(self._onEngineCreated)
@ -149,6 +153,7 @@ class SimulationView(CuraView):
if self._activity == activity:
return
self._activity = activity
self._updateSliceWarningVisibility()
self.activityChanged.emit()
def getSimulationPass(self) -> SimulationPass:
@ -245,7 +250,6 @@ class SimulationView(CuraView):
self._minimum_layer_num = self._current_layer_num
self._startUpdateTopLayers()
self.recalculateStartEndElements()
self.currentLayerNumChanged.emit()
@ -260,7 +264,7 @@ class SimulationView(CuraView):
self._current_layer_num = self._minimum_layer_num
self._startUpdateTopLayers()
self.recalculateStartEndElements()
self.currentLayerNumChanged.emit()
def setPath(self, value: int) -> None:
@ -274,7 +278,6 @@ class SimulationView(CuraView):
self._minimum_path_num = self._current_path_num
self._startUpdateTopLayers()
self.recalculateStartEndElements()
self.currentPathNumChanged.emit()
def setMinimumPath(self, value: int) -> None:
@ -362,24 +365,6 @@ class SimulationView(CuraView):
return 0.0 # If it's still max-float, there are no measurements. Use 0 then.
return self._min_thickness
def recalculateStartEndElements(self):
self.start_elements_index = 0
self.end_elements_index = 0
scene = self.getController().getScene()
for node in DepthFirstIterator(scene.getRoot()): # type: ignore
layer_data = node.callDecoration("getLayerData")
if not layer_data:
continue
# Found a the layer data!
element_counts = layer_data.getElementCounts()
for layer in sorted(element_counts.keys()):
if layer == self._current_layer_num:
break
if self._minimum_layer_num > layer:
self.start_elements_index += element_counts[layer]
self.end_elements_index += element_counts[layer]
def getMaxThickness(self) -> float:
return self._max_thickness
@ -543,11 +528,13 @@ class SimulationView(CuraView):
self._composite_pass.getLayerBindings().append("simulationview")
self._old_composite_shader = self._composite_pass.getCompositeShader()
self._composite_pass.setCompositeShader(self._simulationview_composite_shader)
self._updateSliceWarningVisibility()
elif event.type == Event.ViewDeactivateEvent:
self._controller.getScene().getRoot().childrenChanged.disconnect(self._onSceneChanged)
Application.getInstance().getPreferences().preferenceChanged.disconnect(self._onPreferencesChanged)
self._wireprint_warning_message.hide()
self._slice_first_warning_message.hide()
Application.getInstance().globalContainerStackChanged.disconnect(self._onGlobalStackChanged)
if self._global_container_stack:
self._global_container_stack.propertyChanged.disconnect(self._onPropertyChanged)
@ -599,7 +586,6 @@ class SimulationView(CuraView):
def _startUpdateTopLayers(self) -> None:
if not self._compatibility_mode:
return
self.recalculateStartEndElements()
if self._top_layers_job:
self._top_layers_job.finished.disconnect(self._updateCurrentLayerMesh)
self._top_layers_job.cancel()
@ -661,6 +647,16 @@ class SimulationView(CuraView):
self._updateWithPreferences()
def _updateSliceWarningVisibility(self):
if not self.getActivity()\
and not CuraApplication.getInstance().getPreferences().getValue("general/auto_slice")\
and CuraApplication.getInstance().getPreferences().getValue(self._no_layers_warning_preference):
self._slice_first_warning_message.show()
else:
self._slice_first_warning_message.hide()
def _onDontAskMeAgain(self, checked: bool) -> None:
CuraApplication.getInstance().getPreferences().setValue(self._no_layers_warning_preference, not checked)
class _CreateTopLayersJob(Job):
def __init__(self, scene: "Scene", layer_number: int, solid_layers: int) -> None:

View File

@ -112,7 +112,7 @@ Cura.ExpandableComponent
type_id: 1
})
layerViewTypes.append({
text: catalog.i18nc("@label:listbox", "Feedrate"),
text: catalog.i18nc("@label:listbox", "Speed"),
type_id: 2
})
layerViewTypes.append({

View File

@ -80,7 +80,7 @@ vertex41core =
case 1: // "Line type"
v_color = a_color;
break;
case 2: // "Feedrate"
case 2: // "Speed", or technically 'Feedrate'
v_color = feedrateGradientColor(a_feedrate, u_min_feedrate, u_max_feedrate);
break;
case 3: // "Layer thickness"

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides the Simulation view.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -72,6 +72,7 @@ Window
right: parent.right
}
text: catalog.i18nc("@text:window", "Ultimaker Cura collects anonymous data in order to improve the print quality and user experience. Below is an example of all the data that is shared:")
color: UM.Theme.getColor("text")
wrapMode: Text.WordWrap
renderType: Text.NativeRendering
}

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Submits anonymous slice info. Can be disabled through preferences.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a normal solid mesh view.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Creates an eraser mesh to block the printing of support in certain places",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View File

@ -2,6 +2,6 @@
"name": "Toolbox",
"author": "Ultimaker B.V.",
"version": "1.0.1",
"api": "7.0",
"api": "7.1",
"description": "Find, manage and install new Cura packages."
}

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M19,3H5A2.9,2.9,0,0,0,2,6V9a3.9,3.9,0,0,0,2,3.4V22H20V12.4A3.9,3.9,0,0,0,22,9V6A2.9,2.9,0,0,0,19,3ZM10,5h4V9a2,2,0,0,1-4,0ZM4,9V5H8V9A2,2,0,0,1,4,9ZM18,20H14V15H10v5H6V13a3.7,3.7,0,0,0,3-1.4A3.7,3.7,0,0,0,12,13a3.7,3.7,0,0,0,3-1.4A3.7,3.7,0,0,0,18,13ZM20,9a2,2,0,0,1-4,0V5h4Z" />
</svg>

After

Width:  |  Height:  |  Size: 364 B

View File

@ -14,17 +14,44 @@ Rectangle
Column
{
height: childrenRect.height + 2 * padding
spacing: UM.Theme.getSize("default_margin").width
spacing: UM.Theme.getSize("default_margin").height
width: parent.width
padding: UM.Theme.getSize("wide_margin").height
Label
Item
{
id: heading
text: catalog.i18nc("@label", "Featured")
width: parent.width
color: UM.Theme.getColor("text_medium")
font: UM.Theme.getFont("large")
renderType: Text.NativeRendering
width: parent.width - parent.padding * 2
height: childrenRect.height
Label
{
id: heading
text: catalog.i18nc("@label", "Featured")
width: contentWidth
height: contentHeight
color: UM.Theme.getColor("text_medium")
font: UM.Theme.getFont("large")
renderType: Text.NativeRendering
}
UM.TooltipArea
{
width: childrenRect.width
height: childrenRect.height
anchors.right: parent.right
text: catalog.i18nc("@info:tooltip", "Go to Web Marketplace")
Label
{
text: "<a href='%2'>".arg(toolbox.getWebMarketplaceUrl("materials")) + catalog.i18nc("@label", "Search materials") + "</a>"
width: contentWidth
height: contentHeight
horizontalAlignment: Text.AlignRight
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
linkColor: UM.Theme.getColor("text_link")
onLinkActivated: Qt.openUrlExternally(link)
visible: toolbox.viewCategory === "material"
}
}
}
Grid
{

View File

@ -42,7 +42,7 @@ Item
rightMargin: UM.Theme.getSize("wide_margin").width
}
height: UM.Theme.getSize("toolbox_footer_button").height
text: catalog.i18nc("@info:button", "Quit Cura")
text: catalog.i18nc("@info:button, %1 is the application name", "Quit %1").arg(CuraApplication.applicationDisplayName)
onClicked: toolbox.restart()
}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2018 Ultimaker B.V.
// Copyright (c) 2020 Ultimaker B.V.
// Toolbox is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10
@ -51,32 +51,25 @@ Item
toolbox.viewPage = "overview"
}
}
}
ToolboxTabButton
{
id: installedTabButton
text: catalog.i18nc("@title:tab", "Installed")
active: toolbox.viewCategory == "installed"
enabled: !toolbox.isDownloading
anchors
ToolboxTabButton
{
right: parent.right
rightMargin: UM.Theme.getSize("default_margin").width
id: installedTabButton
text: catalog.i18nc("@title:tab", "Installed")
active: toolbox.viewCategory == "installed"
enabled: !toolbox.isDownloading
onClicked: toolbox.viewCategory = "installed"
width: UM.Theme.getSize("toolbox_header_tab").width + marketplaceNotificationIcon.width - UM.Theme.getSize("default_margin").width
}
onClicked: toolbox.viewCategory = "installed"
width: UM.Theme.getSize("toolbox_header_tab").width + marketplaceNotificationIcon.width - UM.Theme.getSize("default_margin").width
}
Cura.NotificationIcon
{
id: marketplaceNotificationIcon
visible: CuraApplication.getPackageManager().packagesWithUpdate.length > 0
anchors.right: installedTabButton.right
anchors.verticalCenter: installedTabButton.verticalCenter
anchors.right: bar.right
labelText:
{
const itemCount = CuraApplication.getPackageManager().packagesWithUpdate.length
@ -84,6 +77,33 @@ Item
}
}
UM.TooltipArea
{
id: webMarketplaceButtonTooltipArea
width: childrenRect.width
height: parent.height
text: catalog.i18nc("@info:tooltip", "Go to Web Marketplace")
anchors
{
right: parent.right
rightMargin: UM.Theme.getSize("default_margin").width
verticalCenter: parent.verticalCenter
}
onClicked: Qt.openUrlExternally(toolbox.getWebMarketplaceUrl("plugins"))
UM.RecolorImage
{
id: cloudMarketplaceButton
source: "../../images/shop.svg"
color: UM.Theme.getColor(webMarketplaceButtonTooltipArea.containsMouse ? "primary" : "text")
height: parent.height / 2
width: height
anchors.verticalCenter: parent.verticalCenter
sourceSize.width: width
sourceSize.height: height
}
}
ToolboxShadow
{
anchors.top: bar.bottom

View File

@ -20,6 +20,8 @@ UM.Dialog{
maximumHeight: minimumHeight
margin: 0
property string actionButtonText: subscribedPackagesModel.hasIncompatiblePackages && !subscribedPackagesModel.hasCompatiblePackages ? catalog.i18nc("@button", "Dismiss") : catalog.i18nc("@button", "Next")
Rectangle
{
id: root
@ -90,7 +92,7 @@ UM.Dialog{
Label
{
font: UM.Theme.getFont("default")
text: catalog.i18nc("@label", "The following packages can not be installed because of incompatible Cura version:")
text: catalog.i18nc("@label", "The following packages can not be installed because of an incompatible Cura version:")
visible: subscribedPackagesModel.hasIncompatiblePackages
color: UM.Theme.getColor("text")
height: contentHeight + UM.Theme.getSize("default_margin").height
@ -125,26 +127,6 @@ UM.Dialog{
color: UM.Theme.getColor("text")
elide: Text.ElideRight
}
UM.TooltipArea
{
width: childrenRect.width;
height: childrenRect.height;
text: catalog.i18nc("@info:tooltip", "Dismisses the package and won't be shown in this dialog anymore")
anchors.right: parent.right
anchors.verticalCenter: packageIcon.verticalCenter
Label
{
text: "(Dismiss)"
font: UM.Theme.getFont("small")
color: UM.Theme.getColor("text")
MouseArea
{
cursorShape: Qt.PointingHandCursor
anchors.fill: parent
onClicked: handler.dismissIncompatiblePackage(subscribedPackagesModel, model.package_id)
}
}
}
}
}
}
@ -152,14 +134,16 @@ UM.Dialog{
} // End of ScrollView
Cura.ActionButton
Cura.PrimaryButton
{
id: nextButton
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: UM.Theme.getSize("default_margin").height
text: catalog.i18nc("@button", "Next")
text: actionButtonText
onClicked: accept()
leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width
rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width
}
}
}

View File

@ -4,12 +4,12 @@
import QtQuick 2.10
import QtQuick.Dialogs 1.1
import QtQuick.Window 2.2
import QtQuick.Controls 1.4
import QtQuick.Controls 2.3
import QtQuick.Layouts 1.3
import QtQuick.Controls.Styles 1.4
// TODO: Switch to QtQuick.Controls 2.x and remove QtQuick.Controls.Styles
import UM 1.1 as UM
import Cura 1.6 as Cura
UM.Dialog
{
@ -19,50 +19,90 @@ UM.Dialog
minimumHeight: UM.Theme.getSize("license_window_minimum").height
width: minimumWidth
height: minimumHeight
backgroundColor: UM.Theme.getColor("main_background")
margin: screenScaleFactor * 10
Item
ColumnLayout
{
anchors.fill: parent
spacing: UM.Theme.getSize("thick_margin").height
UM.I18nCatalog{id: catalog; name: "cura"}
Label
{
id: licenseHeader
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
text: licenseModel.headerText
Layout.fillWidth: true
text: catalog.i18nc("@label", "You need to accept the license to install the package")
color: UM.Theme.getColor("text")
wrapMode: Text.Wrap
renderType: Text.NativeRendering
}
TextArea
{
id: licenseText
anchors.top: licenseHeader.bottom
anchors.bottom: parent.bottom
Row {
id: packageRow
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: UM.Theme.getSize("default_margin").height
readOnly: true
text: licenseModel.licenseText
height: childrenRect.height
spacing: UM.Theme.getSize("default_margin").width
leftPadding: UM.Theme.getSize("narrow_margin").width
Image
{
id: icon
width: 30 * screenScaleFactor
height: width
fillMode: Image.PreserveAspectFit
source: licenseModel.iconUrl || "../../images/logobot.svg"
mipmap: true
}
Label
{
id: packageName
text: licenseModel.packageName
color: UM.Theme.getColor("text")
font.bold: true
anchors.verticalCenter: icon.verticalCenter
height: contentHeight
wrapMode: Text.Wrap
renderType: Text.NativeRendering
}
}
Cura.ScrollableTextArea
{
Layout.fillWidth: true
Layout.fillHeight: true
anchors.topMargin: UM.Theme.getSize("default_margin").height
textArea.text: licenseModel.licenseText
textArea.readOnly: true
}
}
rightButtons:
[
Button
Cura.PrimaryButton
{
id: acceptButton
anchors.margins: UM.Theme.getSize("default_margin").width
text: catalog.i18nc("@action:button", "Accept")
leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width
rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width
text: licenseModel.acceptButtonText
onClicked: { handler.onLicenseAccepted() }
},
Button
}
]
leftButtons:
[
Cura.SecondaryButton
{
id: declineButton
anchors.margins: UM.Theme.getSize("default_margin").width
text: catalog.i18nc("@action:button", "Decline")
text: licenseModel.declineButtonText
onClicked: { handler.onLicenseDeclined() }
}
]

View File

@ -18,3 +18,11 @@ class CloudApiModel:
cloud_api_root=cloud_api_root,
cloud_api_version=cloud_api_version,
)
## https://api.ultimaker.com/cura-packages/v1/user/packages/{package_id}
@classmethod
def userPackageUrl(cls, package_id: str) -> str:
return (CloudApiModel.api_url_user_packages + "/{package_id}").format(
package_id=package_id
)

View File

@ -0,0 +1,51 @@
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from cura.CuraApplication import CuraApplication
from ..CloudApiModel import CloudApiModel
from ..UltimakerCloudScope import UltimakerCloudScope
class CloudApiClient:
"""Manages Cloud subscriptions
When a package is added to a user's account, the user is 'subscribed' to that package.
Whenever the user logs in on another instance of Cura, these subscriptions can be used to sync the user's plugins
Singleton: use CloudApiClient.getInstance() instead of CloudApiClient()
"""
__instance = None
@classmethod
def getInstance(cls, app: CuraApplication):
if not cls.__instance:
cls.__instance = CloudApiClient(app)
return cls.__instance
def __init__(self, app: CuraApplication) -> None:
if self.__instance is not None:
raise RuntimeError("This is a Singleton. use getInstance()")
self._scope = UltimakerCloudScope(app) # type: UltimakerCloudScope
app.getPackageManager().packageInstalled.connect(self._onPackageInstalled)
def unsubscribe(self, package_id: str) -> None:
url = CloudApiModel.userPackageUrl(package_id)
HttpRequestManager.getInstance().delete(url = url, scope = self._scope)
def _subscribe(self, package_id: str) -> None:
"""You probably don't want to use this directly. All installed packages will be automatically subscribed."""
Logger.debug("Subscribing to {}", package_id)
data = "{\"data\": {\"package_id\": \"%s\", \"sdk_version\": \"%s\"}}" % (package_id, CloudApiModel.sdk_version)
HttpRequestManager.getInstance().put(
url = CloudApiModel.api_url_user_packages,
data = data.encode(),
scope = self._scope
)
def _onPackageInstalled(self, package_id: str):
if CuraApplication.getInstance().getCuraAPI().account.isLoggedIn:
# We might already be subscribed, but checking would take one extra request. Instead, simply subscribe
self._subscribe(package_id)

View File

@ -1,3 +1,6 @@
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json
from typing import Optional
@ -8,11 +11,12 @@ from UM import i18nCatalog
from UM.Logger import Logger
from UM.Message import Message
from UM.Signal import Signal
from cura.CuraApplication import CuraApplication
from cura.CuraApplication import CuraApplication, ApplicationMetadata
from ..CloudApiModel import CloudApiModel
from .SubscribedPackagesModel import SubscribedPackagesModel
from ..UltimakerCloudScope import UltimakerCloudScope
from typing import List, Dict, Any
class CloudPackageChecker(QObject):
def __init__(self, application: CuraApplication) -> None:
@ -22,74 +26,37 @@ class CloudPackageChecker(QObject):
self._application = application # type: CuraApplication
self._scope = UltimakerCloudScope(application)
self._model = SubscribedPackagesModel()
self._message = None # type: Optional[Message]
self._application.initializationFinished.connect(self._onAppInitialized)
self._i18n_catalog = i18nCatalog("cura")
self._sdk_version = ApplicationMetadata.CuraSDKVersion
# This is a plugin, so most of the components required are not ready when
# this is initialized. Therefore, we wait until the application is ready.
def _onAppInitialized(self) -> None:
self._package_manager = self._application.getPackageManager()
# initial check
self._fetchUserSubscribedPackages()
self._onLoginStateChanged()
# check again whenever the login state changes
self._application.getCuraAPI().account.loginStateChanged.connect(self._fetchUserSubscribedPackages)
self._application.getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged)
def _fetchUserSubscribedPackages(self) -> None:
def _onLoginStateChanged(self) -> None:
if self._application.getCuraAPI().account.isLoggedIn:
self._getUserPackages()
self._getUserSubscribedPackages()
elif self._message is not None:
self._message.hide()
self._message = None
def _handleCompatibilityData(self, json_data) -> None:
user_subscribed_packages = [plugin["package_id"] for plugin in json_data]
user_installed_packages = self._package_manager.getUserInstalledPackages()
user_dismissed_packages = self._package_manager.getDismissedPackages()
if user_dismissed_packages:
user_installed_packages += user_dismissed_packages
# We check if there are packages installed in Cloud Marketplace but not in Cura marketplace
package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages))
self._model.setMetadata(json_data)
self._model.addDiscrepancies(package_discrepancy)
self._model.initialize()
if not self._model.hasCompatiblePackages:
return None
if package_discrepancy:
self._handlePackageDiscrepancies()
def _handlePackageDiscrepancies(self) -> None:
Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages")
sync_message = Message(self._i18n_catalog.i18nc(
"@info:generic",
"\nDo you want to sync material and software packages with your account?"),
lifetime=0,
title=self._i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", ))
sync_message.addAction("sync",
name=self._i18n_catalog.i18nc("@action:button", "Sync"),
icon="",
description="Sync your Cloud subscribed packages to your local environment.",
button_align=Message.ActionButtonAlignment.ALIGN_RIGHT)
sync_message.actionTriggered.connect(self._onSyncButtonClicked)
sync_message.show()
def _onSyncButtonClicked(self, sync_message: Message, sync_message_action: str) -> None:
sync_message.hide()
self.discrepancies.emit(self._model)
def _getUserPackages(self) -> None:
Logger.log("d", "Requesting subscribed packages metadata from server.")
def _getUserSubscribedPackages(self) -> None:
Logger.debug("Requesting subscribed packages metadata from server.")
url = CloudApiModel.api_url_user_packages
self._application.getHttpRequestManager().get(url,
callback = self._onUserPackagesRequestFinished,
error_callback = self._onUserPackagesRequestFinished,
scope = self._scope)
def _onUserPackagesRequestFinished(self,
reply: "QNetworkReply",
error: Optional["QNetworkReply.NetworkError"] = None) -> None:
def _onUserPackagesRequestFinished(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"] = None) -> None:
if error is not None or reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200:
Logger.log("w",
"Requesting user packages failed, response code %s while trying to connect to %s",
@ -98,13 +65,50 @@ class CloudPackageChecker(QObject):
try:
json_data = json.loads(bytes(reply.readAll()).decode("utf-8"))
# Check for errors:
if "errors" in json_data:
for error in json_data["errors"]:
Logger.log("e", "%s", error["title"])
return
self._handleCompatibilityData(json_data["data"])
except json.decoder.JSONDecodeError:
Logger.log("w", "Received invalid JSON for user packages")
Logger.log("w", "Received invalid JSON for user subscribed packages from the Web Marketplace")
def _handleCompatibilityData(self, subscribed_packages_payload: List[Dict[str, Any]]) -> None:
user_subscribed_packages = [plugin["package_id"] for plugin in subscribed_packages_payload]
user_installed_packages = self._package_manager.getUserInstalledPackages()
# We need to re-evaluate the dismissed packages
# (i.e. some package might got updated to the correct SDK version in the meantime,
# hence remove them from the Dismissed Incompatible list)
self._package_manager.reEvaluateDismissedPackages(subscribed_packages_payload, self._sdk_version)
user_dismissed_packages = self._package_manager.getDismissedPackages()
if user_dismissed_packages:
user_installed_packages += user_dismissed_packages
# We check if there are packages installed in Web Marketplace but not in Cura marketplace
package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages))
if package_discrepancy:
self._model.addDiscrepancies(package_discrepancy)
self._model.initialize(self._package_manager, subscribed_packages_payload)
self._handlePackageDiscrepancies()
def _handlePackageDiscrepancies(self) -> None:
Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages")
sync_message = Message(self._i18n_catalog.i18nc(
"@info:generic",
"Do you want to sync material and software packages with your account?"),
title = self._i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", ),
lifetime = 0)
sync_message.addAction("sync",
name = self._i18n_catalog.i18nc("@action:button", "Sync"),
icon = "",
description = "Sync your Cloud subscribed packages to your local environment.",
button_align = Message.ActionButtonAlignment.ALIGN_RIGHT)
sync_message.actionTriggered.connect(self._onSyncButtonClicked)
sync_message.show()
self._message = sync_message
def _onSyncButtonClicked(self, sync_message: Message, sync_message_action: str) -> None:
sync_message.hide()
self.discrepancies.emit(self._model)

View File

@ -1,18 +0,0 @@
from cura.CuraApplication import CuraApplication
from ..CloudApiModel import CloudApiModel
from ..UltimakerCloudScope import UltimakerCloudScope
## Manages Cloud subscriptions. When a package is added to a user's account, the user is 'subscribed' to that package
# Whenever the user logs in on another instance of Cura, these subscriptions can be used to sync the user's plugins
class CloudPackageManager:
def __init__(self, app: CuraApplication) -> None:
self._request_manager = app.getHttpRequestManager()
self._scope = UltimakerCloudScope(app)
def subscribe(self, package_id: str) -> None:
data = "{\"data\": {\"package_id\": \"%s\", \"sdk_version\": \"%s\"}}" % (package_id, CloudApiModel.sdk_version)
self._request_manager.put(url=CloudApiModel.api_url_user_packages,
data=data.encode(),
scope=self._scope
)

View File

@ -28,13 +28,12 @@ class DiscrepanciesPresenter(QObject):
assert self._dialog
self._dialog.accepted.connect(lambda: self._onConfirmClicked(model))
@pyqtSlot("QVariant", str)
def dismissIncompatiblePackage(self, model: SubscribedPackagesModel, package_id: str) -> None:
model.dismissPackage(package_id) # update the model to update the view
self._package_manager.dismissPackage(package_id) # adds this package_id as dismissed in the user config file
def _onConfirmClicked(self, model: SubscribedPackagesModel) -> None:
# If there are incompatible packages - automatically dismiss them
if model.getIncompatiblePackages():
self._package_manager.dismissAllIncompatiblePackages(model.getIncompatiblePackages())
# For now, all compatible packages presented to the user should be installed.
# Later, we might remove items for which the user unselected the package
model.setItems(model.getCompatiblePackages())
self.packageMutations.emit(model)
if model.getCompatiblePackages():
model.setItems(model.getCompatiblePackages())
self.packageMutations.emit(model)

View File

@ -1,3 +1,6 @@
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import tempfile
from typing import Dict, List, Any
@ -62,7 +65,8 @@ class DownloadPresenter:
"received": 0,
"total": 1, # make sure this is not considered done yet. Also divByZero-safe
"file_written": None,
"request_data": request_data
"request_data": request_data,
"package_model": item
}
self._started = True
@ -80,11 +84,9 @@ class DownloadPresenter:
return DownloadPresenter(self._app)
def _createProgressMessage(self) -> Message:
return Message(i18n_catalog.i18nc(
"@info:generic",
"\nSyncing..."),
return Message(i18n_catalog.i18nc("@info:generic", "Syncing..."),
lifetime = 0,
use_inactivity_timer=False,
use_inactivity_timer = False,
progress = 0.0,
title = i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", ))
@ -92,7 +94,7 @@ class DownloadPresenter:
self._progress[package_id]["received"] = self._progress[package_id]["total"]
try:
with tempfile.NamedTemporaryFile(mode ="wb+", suffix =".curapackage", delete = False) as temp_file:
with tempfile.NamedTemporaryFile(mode = "wb+", suffix = ".curapackage", delete = False) as temp_file:
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
while bytes_read:
temp_file.write(bytes_read)
@ -128,7 +130,14 @@ class DownloadPresenter:
if not item["file_written"]:
return False
success_items = {package_id : value["file_written"] for package_id, value in self._progress.items()}
success_items = {
package_id:
{
"package_path": value["file_written"],
"icon_url": value["package_model"]["icon_url"]
}
for package_id, value in self._progress.items()
}
error_items = [package_id for package_id in self._error]
self._progress_message.hide()

View File

@ -6,31 +6,52 @@ catalog = i18nCatalog("cura")
# Model for the ToolboxLicenseDialog
class LicenseModel(QObject):
dialogTitleChanged = pyqtSignal()
headerChanged = pyqtSignal()
licenseTextChanged = pyqtSignal()
DEFAULT_DECLINE_BUTTON_TEXT = catalog.i18nc("@button", "Decline")
ACCEPT_BUTTON_TEXT = catalog.i18nc("@button", "Agree")
def __init__(self) -> None:
dialogTitleChanged = pyqtSignal()
packageNameChanged = pyqtSignal()
licenseTextChanged = pyqtSignal()
iconChanged = pyqtSignal()
def __init__(self, decline_button_text: str = DEFAULT_DECLINE_BUTTON_TEXT) -> None:
super().__init__()
self._current_page_idx = 0
self._page_count = 1
self._dialogTitle = ""
self._header_text = ""
self._license_text = ""
self._package_name = ""
self._icon_url = ""
self._decline_button_text = decline_button_text
@pyqtProperty(str, constant = True)
def acceptButtonText(self):
return self.ACCEPT_BUTTON_TEXT
@pyqtProperty(str, constant = True)
def declineButtonText(self):
return self._decline_button_text
@pyqtProperty(str, notify=dialogTitleChanged)
def dialogTitle(self) -> str:
return self._dialogTitle
@pyqtProperty(str, notify=headerChanged)
def headerText(self) -> str:
return self._header_text
@pyqtProperty(str, notify=packageNameChanged)
def packageName(self) -> str:
return self._package_name
def setPackageName(self, name: str) -> None:
self._header_text = name + ": " + catalog.i18nc("@label", "This plugin contains a license.\nYou need to accept this license to install this plugin.\nDo you agree with the terms below?")
self.headerChanged.emit()
self._package_name = name
self.packageNameChanged.emit()
@pyqtProperty(str, notify=iconChanged)
def iconUrl(self) -> str:
return self._icon_url
def setIconUrl(self, url: str):
self._icon_url = url
self.iconChanged.emit()
@pyqtProperty(str, notify=licenseTextChanged)
def licenseText(self) -> str:
@ -50,6 +71,7 @@ class LicenseModel(QObject):
self._updateDialogTitle()
def _updateDialogTitle(self):
self._dialogTitle = catalog.i18nc("@title:window", "Plugin License Agreement ({}/{})"
.format(self._current_page_idx + 1, self._page_count))
self._dialogTitle = catalog.i18nc("@title:window", "Plugin License Agreement")
if self._page_count > 1:
self._dialogTitle = self._dialogTitle + " ({}/{})".format(self._current_page_idx + 1, self._page_count)
self.dialogTitleChanged.emit()

View File

@ -1,8 +1,10 @@
import os
from typing import Dict, Optional, List
from collections import OrderedDict
from typing import Dict, Optional, List, Any
from PyQt5.QtCore import QObject, pyqtSlot
from UM.Logger import Logger
from UM.PackageManager import PackageManager
from UM.Signal import Signal
from cura.CuraApplication import CuraApplication
@ -11,12 +13,20 @@ from UM.i18n import i18nCatalog
from .LicenseModel import LicenseModel
## Call present() to show a licenseDialog for a set of packages
# licenseAnswers emits a list of Dicts containing answers when the user has made a choice for all provided packages
class LicensePresenter(QObject):
"""Presents licenses for a set of packages for the user to accept or reject.
Call present() exactly once to show a licenseDialog for a set of packages
Before presenting another set of licenses, create a new instance using resetCopy().
licenseAnswers emits a list of Dicts containing answers when the user has made a choice for all provided packages.
"""
def __init__(self, app: CuraApplication) -> None:
super().__init__()
self._presented = False
"""Whether present() has been called and state is expected to be initialized"""
self._catalog = i18nCatalog("cura")
self._dialog = None # type: Optional[QObject]
self._package_manager = app.getPackageManager() # type: PackageManager
# Emits List[Dict[str, [Any]] containing for example
@ -25,7 +35,9 @@ class LicensePresenter(QObject):
self._current_package_idx = 0
self._package_models = [] # type: List[Dict]
self._license_model = LicenseModel() # type: LicenseModel
decline_button_text = self._catalog.i18nc("@button", "Decline and remove from account")
self._license_model = LicenseModel(decline_button_text=decline_button_text) # type: LicenseModel
self._page_count = 0
self._app = app
@ -34,21 +46,36 @@ class LicensePresenter(QObject):
## Show a license dialog for multiple packages where users can read a license and accept or decline them
# \param plugin_path: Root directory of the Toolbox plugin
# \param packages: Dict[package id, file path]
def present(self, plugin_path: str, packages: Dict[str, str]) -> None:
def present(self, plugin_path: str, packages: Dict[str, Dict[str, str]]) -> None:
if self._presented:
Logger.error("{clazz} is single-use. Create a new {clazz} instead", clazz=self.__class__.__name__)
return
path = os.path.join(plugin_path, self._compatibility_dialog_path)
self._initState(packages)
if self._page_count == 0:
self.licenseAnswers.emit(self._package_models)
return
if self._dialog is None:
context_properties = {
"catalog": i18nCatalog("cura"),
"catalog": self._catalog,
"licenseModel": self._license_model,
"handler": self
}
self._dialog = self._app.createQmlComponent(path, context_properties)
self._license_model.setPageCount(len(self._package_models))
self._presentCurrentPackage()
self._presented = True
def resetCopy(self) -> "LicensePresenter":
"""Clean up and return a new copy with the same settings such as app"""
if self._dialog:
self._dialog.close()
self.licenseAnswers.disconnectAll()
return LicensePresenter(self._app)
@pyqtSlot()
def onLicenseAccepted(self) -> None:
@ -60,32 +87,41 @@ class LicensePresenter(QObject):
self._package_models[self._current_package_idx]["accepted"] = False
self._checkNextPage()
def _initState(self, packages: Dict[str, str]) -> None:
self._package_models = [
{
"package_id" : package_id,
"package_path" : package_path,
"accepted" : None #: None: no answer yet
}
for package_id, package_path in packages.items()
]
def _initState(self, packages: Dict[str, Dict[str, Any]]) -> None:
implicitly_accepted_count = 0
for package_id, item in packages.items():
item["package_id"] = package_id
item["licence_content"] = self._package_manager.getPackageLicense(item["package_path"])
if item["licence_content"] is None:
# Implicitly accept when there is no license
item["accepted"] = True
implicitly_accepted_count = implicitly_accepted_count + 1
self._package_models.append(item)
else:
item["accepted"] = None #: None: no answer yet
# When presenting the packages, we want to show packages which have a license first.
# In fact, we don't want to show the others at all because they are implicitly accepted
self._package_models.insert(0, item)
CuraApplication.getInstance().processEvents()
self._page_count = len(self._package_models) - implicitly_accepted_count
self._license_model.setPageCount(self._page_count)
def _presentCurrentPackage(self) -> None:
package_model = self._package_models[self._current_package_idx]
license_content = self._package_manager.getPackageLicense(package_model["package_path"])
if license_content is None:
# Implicitly accept when there is no license
self.onLicenseAccepted()
return
package_info = self._package_manager.getPackageInfo(package_model["package_path"])
self._license_model.setCurrentPageIdx(self._current_package_idx)
self._license_model.setPackageName(package_model["package_id"])
self._license_model.setLicenseText(license_content)
self._license_model.setPackageName(package_info["display_name"])
self._license_model.setIconUrl(package_model["icon_url"])
self._license_model.setLicenseText(package_model["licence_content"])
if self._dialog:
self._dialog.open() # Does nothing if already open
def _checkNextPage(self) -> None:
if self._current_package_idx + 1 < len(self._package_models):
if self._current_package_idx + 1 < self._page_count:
self._current_package_idx += 1
self._presentCurrentPackage()
else:

View File

@ -2,9 +2,12 @@
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt, pyqtProperty, pyqtSlot
from UM.PackageManager import PackageManager
from UM.Qt.ListModel import ListModel
from UM.Version import Version
from cura import ApplicationMetadata
from UM.Logger import Logger
from typing import List, Dict, Any
@ -37,27 +40,18 @@ class SubscribedPackagesModel(ListModel):
return True
return False
# Sets the "is_compatible" to True for the given package, in memory
@pyqtSlot()
def dismissPackage(self, package_id: str) -> None:
package = self.find(key="package_id", value=package_id)
if package != -1:
self.setProperty(package, property="is_dismissed", value=True)
Logger.debug("Package {} has been dismissed".format(package_id))
def setMetadata(self, data: List[Dict[str, List[Any]]]) -> None:
self._metadata = data
def addDiscrepancies(self, discrepancy: List[str]) -> None:
self._discrepancies = discrepancy
def getCompatiblePackages(self):
return [x for x in self._items if x["is_compatible"]]
def getCompatiblePackages(self) -> List[Dict[str, Any]]:
return [package for package in self._items if package["is_compatible"]]
def initialize(self) -> None:
def getIncompatiblePackages(self) -> List[str]:
return [package["package_id"] for package in self._items if not package["is_compatible"]]
def initialize(self, package_manager: PackageManager, subscribed_packages_payload: List[Dict[str, Any]]) -> None:
self._items.clear()
for item in self._metadata:
for item in subscribed_packages_payload:
if item["package_id"] not in self._discrepancies:
continue
package = {
@ -68,15 +62,13 @@ class SubscribedPackagesModel(ListModel):
"md5_hash": item["md5_hash"],
"is_dismissed": False,
}
if self._sdk_version not in item["sdk_versions"]:
package.update({"is_compatible": False})
else:
package.update({"is_compatible": True})
compatible = any(package_manager.isPackageCompatible(Version(version)) for version in item["sdk_versions"])
package.update({"is_compatible": compatible})
try:
package.update({"icon_url": item["icon_url"]})
except KeyError: # There is no 'icon_url" in the response payload for this package
package.update({"icon_url": ""})
self._items.append(package)
self.setItems(self._items)

View File

@ -1,12 +1,14 @@
import os
from typing import List, Dict, Any, cast
from UM import i18n_catalog
from UM.Extension import Extension
from UM.Logger import Logger
from UM.Message import Message
from UM.PluginRegistry import PluginRegistry
from cura.CuraApplication import CuraApplication
from .CloudPackageChecker import CloudPackageChecker
from .CloudPackageManager import CloudPackageManager
from .CloudApiClient import CloudApiClient
from .DiscrepanciesPresenter import DiscrepanciesPresenter
from .DownloadPresenter import DownloadPresenter
from .LicensePresenter import LicensePresenter
@ -24,7 +26,7 @@ from .SubscribedPackagesModel import SubscribedPackagesModel
# - The DownloadPresenter shows a download progress dialog. It emits A tuple of succeeded and failed downloads
# - The LicensePresenter extracts licenses from the downloaded packages and presents a license for each package to
# be installed. It emits the `licenseAnswers` signal for accept or declines
# - The CloudPackageManager removes the declined packages from the account
# - The CloudApiClient removes the declined packages from the account
# - The SyncOrchestrator uses PackageManager to install the downloaded packages and delete temp files.
# - The RestartApplicationPresenter notifies the user that a restart is required for changes to take effect
class SyncOrchestrator(Extension):
@ -36,7 +38,8 @@ class SyncOrchestrator(Extension):
self._name = "SyncOrchestrator"
self._package_manager = app.getPackageManager()
self._cloud_package_manager = CloudPackageManager(app)
# Keep a reference to the CloudApiClient. it watches for installed packages and subscribes to them
self._cloud_api = CloudApiClient.getInstance(app) # type: CloudApiClient
self._checker = CloudPackageChecker(app) # type: CloudPackageChecker
self._checker.discrepancies.connect(self._onDiscrepancies)
@ -61,32 +64,39 @@ class SyncOrchestrator(Extension):
self._download_presenter.download(mutations)
## Called when a set of packages have finished downloading
# \param success_items: Dict[package_id, file_path]
# \param success_items: Dict[package_id, Dict[str, str]]
# \param error_items: List[package_id]
def _onDownloadFinished(self, success_items: Dict[str, str], error_items: List[str]) -> None:
# todo handle error items
def _onDownloadFinished(self, success_items: Dict[str, Dict[str, str]], error_items: List[str]) -> None:
if error_items:
message = i18n_catalog.i18nc("@info:generic", "{} plugins failed to download".format(len(error_items)))
self._showErrorMessage(message)
plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath(self.getPluginId()))
self._license_presenter = self._license_presenter.resetCopy()
self._license_presenter.licenseAnswers.connect(self._onLicenseAnswers)
self._license_presenter.present(plugin_path, success_items)
# Called when user has accepted / declined all licenses for the downloaded packages
def _onLicenseAnswers(self, answers: List[Dict[str, Any]]) -> None:
Logger.debug("Got license answers: {}", answers)
has_changes = False # True when at least one package is installed
for item in answers:
if item["accepted"]:
# install and subscribe packages
if not self._package_manager.installPackage(item["package_path"]):
Logger.error("could not install {}".format(item["package_id"]))
message = "Could not install {}".format(item["package_id"])
self._showErrorMessage(message)
continue
self._cloud_package_manager.subscribe(item["package_id"])
has_changes = True
else:
# todo unsubscribe declined packages
pass
self._cloud_api.unsubscribe(item["package_id"])
# delete temp file
os.remove(item["package_path"])
if has_changes:
self._restart_presenter.present()
## Logs an error and shows it to the user
def _showErrorMessage(self, text: str):
Logger.error(text)
Message(text, lifetime=0).show()

View File

@ -67,17 +67,22 @@ class PackagesModel(ListModel):
links_dict = {}
if "data" in package:
# Links is a list of dictionaries with "title" and "url". Convert this list into a dict so it's easier
# to process.
link_list = package["data"]["links"] if "links" in package["data"] else []
links_dict = {d["title"]: d["url"] for d in link_list}
# This code never gets executed because the API response does not contain "supported_configs" in it
# It is so because 2y ago when this was created - it did contain it. But it was a prototype only
# and never got to production. As agreed with the team, it'll stay here for now, in case we decide to rework and use it
# The response payload has been changed. Please see:
# https://github.com/Ultimaker/Cura/compare/CURA-7072-temp?expand=1
if "supported_configs" in package["data"]:
if len(package["data"]["supported_configs"]) > 0:
has_configs = True
configs_model = ConfigsModel()
configs_model.setConfigs(package["data"]["supported_configs"])
# Links is a list of dictionaries with "title" and "url". Convert this list into a dict so it's easier
# to process.
link_list = package["data"]["links"] if "links" in package["data"] else []
links_dict = {d["title"]: d["url"] for d in link_list}
if "author_id" not in package["author"] or "display_name" not in package["author"]:
package["author"]["author_id"] = ""
package["author"]["display_name"] = ""

View File

@ -16,12 +16,12 @@ from UM.i18n import i18nCatalog
from UM.Version import Version
from cura import ApplicationMetadata
from cura.CuraApplication import CuraApplication
from cura.Machines.ContainerTree import ContainerTree
from .CloudApiModel import CloudApiModel
from .AuthorsModel import AuthorsModel
from .CloudSync.CloudPackageManager import CloudPackageManager
from .CloudSync.LicenseModel import LicenseModel
from .PackagesModel import PackagesModel
from .UltimakerCloudScope import UltimakerCloudScope
@ -32,6 +32,13 @@ if TYPE_CHECKING:
i18n_catalog = i18nCatalog("cura")
DEFAULT_MARKETPLACE_ROOT = "https://marketplace.ultimaker.com" # type: str
try:
from cura.CuraVersion import CuraMarketplaceRoot
except ImportError:
CuraMarketplaceRoot = DEFAULT_MARKETPLACE_ROOT
# todo Remove license and download dialog, use SyncOrchestrator instead
## Provides a marketplace for users to download plugins an materials
@ -44,7 +51,6 @@ class Toolbox(QObject, Extension):
self._sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int]
# Network:
self._cloud_package_manager = CloudPackageManager(application) # type: CloudPackageManager
self._download_request_data = None # type: Optional[HttpRequestData]
self._download_progress = 0 # type: float
self._is_downloading = False # type: bool
@ -147,17 +153,14 @@ class Toolbox(QObject, Extension):
self._application.getHttpRequestManager().put(url, data = data.encode(), scope = self._scope)
@pyqtSlot(str)
def subscribe(self, package_id: str) -> None:
self._cloud_package_manager.subscribe(package_id)
def getLicenseDialogPluginFileLocation(self) -> str:
return self._license_dialog_plugin_file_location
def openLicenseDialog(self, plugin_name: str, license_content: str, plugin_file_location: str) -> None:
def openLicenseDialog(self, plugin_name: str, license_content: str, plugin_file_location: str, icon_url: str) -> None:
# Set page 1/1 when opening the dialog for a single package
self._license_model.setCurrentPageIdx(0)
self._license_model.setPageCount(1)
self._license_model.setIconUrl(icon_url)
self._license_model.setPackageName(plugin_name)
self._license_model.setLicenseText(license_content)
@ -376,7 +379,6 @@ class Toolbox(QObject, Extension):
def onLicenseAccepted(self):
self.closeLicenseDialog.emit()
package_id = self.install(self.getLicenseDialogPluginFileLocation())
self.subscribe(package_id)
@pyqtSlot()
@ -670,14 +672,16 @@ class Toolbox(QObject, Extension):
return
license_content = self._package_manager.getPackageLicense(file_path)
package_id = package_info["package_id"]
if license_content is not None:
self.openLicenseDialog(package_info["package_id"], license_content, file_path)
# get the icon url for package_id, make sure the result is a string, never None
icon_url = next((x["icon_url"] for x in self.packagesModel.items if x["id"] == package_id), None) or ""
self.openLicenseDialog(package_info["display_name"], license_content, file_path, icon_url)
return
package_id = self.install(file_path)
if package_id != package_info["package_id"]:
Logger.error("Installed package {} does not match {}".format(package_id, package_info["package_id"]))
self.subscribe(package_id)
installed_id = self.install(file_path)
if installed_id != package_id:
Logger.error("Installed package {} does not match {}".format(installed_id, package_id))
# Getter & Setters for Properties:
# --------------------------------------------------------------------------
@ -699,14 +703,14 @@ class Toolbox(QObject, Extension):
def isDownloading(self) -> bool:
return self._is_downloading
def setActivePackage(self, package: Dict[str, Any]) -> None:
def setActivePackage(self, package: QObject) -> None:
if self._active_package != package:
self._active_package = package
self.activePackageChanged.emit()
## The active package is the package that is currently being downloaded
@pyqtProperty(QObject, fset = setActivePackage, notify = activePackageChanged)
def activePackage(self) -> Optional[Dict[str, Any]]:
def activePackage(self) -> Optional[QObject]:
return self._active_package
def setViewCategory(self, category: str = "plugin") -> None:
@ -770,6 +774,13 @@ class Toolbox(QObject, Extension):
def materialsGenericModel(self) -> PackagesModel:
return self._materials_generic_model
@pyqtSlot(str, result = str)
def getWebMarketplaceUrl(self, page: str) -> str:
root = CuraMarketplaceRoot
if root == "":
root = DEFAULT_MARKETPLACE_ROOT
return root + "/app/cura/" + page
# Filter Models:
# --------------------------------------------------------------------------
@pyqtSlot(str, str, str)

View File

@ -6,6 +6,9 @@ from cura.API import Account
from cura.CuraApplication import CuraApplication
## Add a Authorization header to the request for Ultimaker Cloud Api requests.
# When the user is not logged in or a token is not available, a warning will be logged
# Also add the user agent headers (see DefaultUserAgentScope)
class UltimakerCloudScope(DefaultUserAgentScope):
def __init__(self, application: CuraApplication):
super().__init__(application)

View File

@ -108,7 +108,7 @@ class TrimeshReader(MeshReader):
mesh.merge_vertices()
mesh.remove_unreferenced_vertices()
mesh.fix_normals()
mesh_data = self._toMeshData(mesh)
mesh_data = self._toMeshData(mesh, file_name)
file_base_name = os.path.basename(file_name)
new_node = CuraSceneNode()
@ -133,9 +133,10 @@ class TrimeshReader(MeshReader):
## Converts a Trimesh to Uranium's MeshData.
# \param tri_node A Trimesh containing the contents of a file that was
# just read.
# \param file_name The full original filename used to watch for changes
# \return Mesh data from the Trimesh in a way that Uranium can understand
# it.
def _toMeshData(self, tri_node: trimesh.base.Trimesh) -> MeshData:
def _toMeshData(self, tri_node: trimesh.base.Trimesh, file_name: str = "") -> MeshData:
tri_faces = tri_node.faces
tri_vertices = tri_node.vertices
@ -157,5 +158,5 @@ class TrimeshReader(MeshReader):
indices = numpy.asarray(indices, dtype = numpy.int32)
normals = calculateNormalsFromIndexedVertices(vertices, indices, face_count)
mesh_data = MeshData(vertices = vertices, indices = indices, normals = normals)
mesh_data = MeshData(vertices = vertices, indices = indices, normals = normals, file_name = file_name)
return mesh_data

View File

@ -3,5 +3,5 @@
"author": "Ultimaker B.V.",
"version": "1.0.0",
"description": "Provides support for reading model files.",
"api": "7.0.0"
"api": "7.1.0"
}

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