Merge branch 'master' into CURA-6627_Store_extra_data_project_files

This commit is contained in:
Ghostkeeper 2020-01-21 15:14:45 +01:00
commit a3825c1f14
No known key found for this signature in database
GPG Key ID: 37E2020986774393
51 changed files with 1403 additions and 421 deletions

View File

@ -6,6 +6,7 @@ on:
- master - master
- 'WIP**' - 'WIP**'
- '4.*' - '4.*'
- 'CURA-*'
pull_request: pull_request:
jobs: jobs:
build: build:

View File

@ -56,6 +56,13 @@ function(cura_add_test)
endif() endif()
endfunction() 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}") cura_add_test(NAME pytest-main DIRECTORY ${CMAKE_SOURCE_DIR}/tests PYTHONPATH "${CMAKE_SOURCE_DIR}|${URANIUM_DIR}")
file(GLOB_RECURSE _plugins plugins/*/__init__.py) file(GLOB_RECURSE _plugins plugins/*/__init__.py)

View File

@ -69,7 +69,7 @@ class Arrange:
points = copy.deepcopy(vertices._points) points = copy.deepcopy(vertices._points)
# After scaling (like up to 0.1 mm) the node might not have points # After scaling (like up to 0.1 mm) the node might not have points
if not points: if not points.size:
continue continue
shape_arr = ShapeArray.fromPolygon(points, scale = scale) shape_arr = ShapeArray.fromPolygon(points, scale = scale)

View File

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import os import os
@ -1827,15 +1827,21 @@ class CuraApplication(QtApplication):
def _onContextMenuRequested(self, x: float, y: float) -> None: def _onContextMenuRequested(self, x: float, y: float) -> None:
# Ensure we select the object if we request a context menu over an object without having a selection. # Ensure we select the object if we request a context menu over an object without having a selection.
if not Selection.hasSelection(): if Selection.hasSelection():
node = self.getController().getScene().findObject(cast(SelectionPass, self.getRenderer().getRenderPass("selection")).getIdAtPosition(x, y)) return
if node: selection_pass = cast(SelectionPass, self.getRenderer().getRenderPass("selection"))
parent = node.getParent() if not selection_pass: # If you right-click before the rendering has been initialised there might not be a selection pass yet.
while(parent and parent.callDecoration("isGroup")): print("--------------ding! Got the crash.")
node = parent return
parent = node.getParent() node = self.getController().getScene().findObject(selection_pass.getIdAtPosition(x, y))
if not node:
return
parent = node.getParent()
while parent and parent.callDecoration("isGroup"):
node = parent
parent = node.getParent()
Selection.add(node) Selection.add(node)
@pyqtSlot() @pyqtSlot()
def showMoreInformationDialogForAnonymousDataCollection(self): def showMoreInformationDialogForAnonymousDataCollection(self):

View File

@ -15,7 +15,7 @@ if TYPE_CHECKING:
class CuraPackageManager(PackageManager): class CuraPackageManager(PackageManager):
def __init__(self, application: "QtApplication", parent: Optional["QObject"] = None): def __init__(self, application: "QtApplication", parent: Optional["QObject"] = None) -> None:
super().__init__(application, parent) super().__init__(application, parent)
def initialize(self) -> None: def initialize(self) -> None:

View File

@ -34,7 +34,7 @@ class MaterialBrandsModel(BaseMaterialsModel):
brand_item_list = [] brand_item_list = []
brand_group_dict = {} brand_group_dict = {}
# Part 1: Generate the entire tree of brands -> material types -> spcific materials # Part 1: Generate the entire tree of brands -> material types -> specific materials
for root_material_id, container_node in self._available_materials.items(): for root_material_id, container_node in self._available_materials.items():
# Do not include the materials from a to-be-removed package # Do not include the materials from a to-be-removed package
if bool(container_node.getMetaDataEntry("removed", False)): if bool(container_node.getMetaDataEntry("removed", False)):

View File

@ -51,7 +51,7 @@ class VariantNode(ContainerNode):
# Find all the materials for this variant's name. # Find all the materials for this variant's name.
else: # Printer has its own material profiles. Look for material profiles with this printer's definition. else: # Printer has its own material profiles. Look for material profiles with this printer's definition.
base_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = "fdmprinter") base_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = "fdmprinter")
printer_specific_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = self.machine.container_id, variant_name = None) printer_specific_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = self.machine.container_id)
variant_specific_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = self.machine.container_id, variant_name = self.variant_name) # If empty_variant, this won't return anything. variant_specific_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = self.machine.container_id, variant_name = self.variant_name) # If empty_variant, this won't return anything.
materials_per_base_file = {material["base_file"]: material for material in base_materials} materials_per_base_file = {material["base_file"]: material for material in base_materials}
materials_per_base_file.update({material["base_file"]: material for material in printer_specific_materials}) # Printer-specific profiles override global ones. materials_per_base_file.update({material["base_file"]: material for material in printer_specific_materials}) # Printer-specific profiles override global ones.

View File

@ -122,6 +122,6 @@ class _ObjectOrder:
# \param order List of indices in which to print objects, ordered by printing # \param order List of indices in which to print objects, ordered by printing
# order. # order.
# \param todo: List of indices which are not yet inserted into the order list. # \param todo: List of indices which are not yet inserted into the order list.
def __init__(self, order: List[SceneNode], todo: List[SceneNode]): def __init__(self, order: List[SceneNode], todo: List[SceneNode]) -> None:
self.order = order self.order = order
self.todo = todo self.todo = todo

View File

@ -8,7 +8,7 @@ from UM.Scene.SceneNode import SceneNode
## A specialised operation designed specifically to modify the previous operation. ## A specialised operation designed specifically to modify the previous operation.
class PlatformPhysicsOperation(Operation): class PlatformPhysicsOperation(Operation):
def __init__(self, node: SceneNode, translation: Vector): def __init__(self, node: SceneNode, translation: Vector) -> None:
super().__init__() super().__init__()
self._node = node self._node = node
self._old_transformation = node.getLocalTransformation() self._old_transformation = node.getLocalTransformation()

View File

@ -14,7 +14,7 @@ class SetParentOperation(Operation.Operation):
# #
# \param node The node which will be reparented. # \param node The node which will be reparented.
# \param parent_node The node which will be the parent. # \param parent_node The node which will be the parent.
def __init__(self, node: SceneNode, parent_node: Optional[SceneNode]): def __init__(self, node: SceneNode, parent_node: Optional[SceneNode]) -> None:
super().__init__() super().__init__()
self._node = node self._node = node
self._parent = parent_node self._parent = parent_node

View File

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, QVariant # For communicating data and events to Qt. from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, QVariant # For communicating data and events to Qt.
@ -275,6 +275,25 @@ class ExtruderManager(QObject):
Logger.log("e", "Unable to find one or more of the extruders in %s", used_extruder_stack_ids) Logger.log("e", "Unable to find one or more of the extruders in %s", used_extruder_stack_ids)
return [] return []
## Get the extruder that the print will start with.
#
# This should mirror the implementation in CuraEngine of
# ``FffGcodeWriter::getStartExtruder()``.
def getInitialExtruderNr(self) -> int:
application = cura.CuraApplication.CuraApplication.getInstance()
global_stack = application.getGlobalContainerStack()
# Starts with the adhesion extruder.
if global_stack.getProperty("adhesion_type", "value") != "none":
return global_stack.getProperty("adhesion_extruder_nr", "value")
# No adhesion? Well maybe there is still support brim.
if (global_stack.getProperty("support_enable", "value") or global_stack.getProperty("support_tree_enable", "value")) and global_stack.getProperty("support_brim_enable", "value"):
return global_stack.getProperty("support_infill_extruder_nr", "value")
# REALLY no adhesion? Use the first used extruder.
return self.getUsedExtruderStacks()[0].getProperty("extruder_nr", "value")
## Removes the container stack and user profile for the extruders for a specific machine. ## Removes the container stack and user profile for the extruders for a specific machine.
# #
# \param machine_id The machine to remove the extruders for. # \param machine_id The machine to remove the extruders for.

View File

@ -13,28 +13,46 @@ export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}"
cd "${PROJECT_DIR}" cd "${PROJECT_DIR}"
# #
# Clone Uranium and set PYTHONPATH first # Clone Uranium and set PYTHONPATH first
# #
# Check the branch to use: # Check the branch to use for Uranium.
# 1. Use the Uranium branch with the branch same if it exists. # It tries the following branch names and uses the first one that's available.
# 2. Otherwise, use the default branch name "master" # - GITHUB_HEAD_REF: the branch name of a PR. If it's not a PR, it will be empty.
# - GITHUB_BASE_REF: the branch a PR is based on. If it's not a PR, it will be empty.
# - GITHUB_REF: the branch name if it's a branch on the repository;
# refs/pull/123/merge if it's a pull_request.
# - master: the master branch. It should always exist.
# For debugging.
echo "GITHUB_REF: ${GITHUB_REF}" echo "GITHUB_REF: ${GITHUB_REF}"
echo "GITHUB_HEAD_REF: ${GITHUB_HEAD_REF}"
echo "GITHUB_BASE_REF: ${GITHUB_BASE_REF}" echo "GITHUB_BASE_REF: ${GITHUB_BASE_REF}"
GIT_REF_NAME="${GITHUB_REF}" GIT_REF_NAME_LIST=( "${GITHUB_HEAD_REF}" "${GITHUB_BASE_REF}" "${GITHUB_REF}" "master" )
if [ -n "${GITHUB_BASE_REF}" ]; then for git_ref_name in "${GIT_REF_NAME_LIST[@]}"
GIT_REF_NAME="${GITHUB_BASE_REF}" do
fi if [ -z "${git_ref_name}" ]; then
GIT_REF_NAME="$(basename "${GIT_REF_NAME}")" continue
fi
URANIUM_BRANCH="${GIT_REF_NAME:-master}" git_ref_name="$(basename "${git_ref_name}")"
output="$(git ls-remote --heads https://github.com/Ultimaker/Uranium.git "${URANIUM_BRANCH}")" # Skip refs/pull/1234/merge as pull requests use it as GITHUB_REF
if [ -z "${output}" ]; then if [[ "${git_ref_name}" == "merge" ]]; then
echo "Could not find Uranium banch ${URANIUM_BRANCH}, fallback to use master." echo "Skip [${git_ref_name}]"
URANIUM_BRANCH="master" continue
fi fi
URANIUM_BRANCH="${git_ref_name}"
output="$(git ls-remote --heads https://github.com/Ultimaker/Uranium.git "${URANIUM_BRANCH}")"
if [ -n "${output}" ]; then
echo "Found Uranium branch [${URANIUM_BRANCH}]."
break
else
echo "Could not find Uranium banch [${URANIUM_BRANCH}], try next."
fi
done
echo "Using Uranium branch ${URANIUM_BRANCH} ..." echo "Using Uranium branch ${URANIUM_BRANCH} ..."
git clone --depth=1 -b "${URANIUM_BRANCH}" https://github.com/Ultimaker/Uranium.git "${PROJECT_DIR}"/Uranium git clone --depth=1 -b "${URANIUM_BRANCH}" https://github.com/Ultimaker/Uranium.git "${PROJECT_DIR}"/Uranium

View File

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import numpy import numpy
@ -171,146 +171,145 @@ class StartSliceJob(Job):
self.setResult(StartJobResult.ObjectSettingError) self.setResult(StartJobResult.ObjectSettingError)
return return
with self._scene.getSceneLock(): # Remove old layer data.
# Remove old layer data. for node in DepthFirstIterator(self._scene.getRoot()):
for node in DepthFirstIterator(self._scene.getRoot()): if node.callDecoration("getLayerData") and node.callDecoration("getBuildPlateNumber") == self._build_plate_number:
if node.callDecoration("getLayerData") and node.callDecoration("getBuildPlateNumber") == self._build_plate_number: # Singe we walk through all nodes in the scene, they always have a parent.
# Singe we walk through all nodes in the scene, they always have a parent. cast(SceneNode, node.getParent()).removeChild(node)
cast(SceneNode, node.getParent()).removeChild(node) break
break
# Get the objects in their groups to print. # Get the objects in their groups to print.
object_groups = [] object_groups = []
if stack.getProperty("print_sequence", "value") == "one_at_a_time": if stack.getProperty("print_sequence", "value") == "one_at_a_time":
for node in OneAtATimeIterator(self._scene.getRoot()): for node in OneAtATimeIterator(self._scene.getRoot()):
temp_list = []
# Node can't be printed, so don't bother sending it.
if getattr(node, "_outside_buildarea", False):
continue
# Filter on current build plate
build_plate_number = node.callDecoration("getBuildPlateNumber")
if build_plate_number is not None and build_plate_number != self._build_plate_number:
continue
children = node.getAllChildren()
children.append(node)
for child_node in children:
mesh_data = child_node.getMeshData()
if mesh_data and mesh_data.getVertices() is not None:
temp_list.append(child_node)
if temp_list:
object_groups.append(temp_list)
Job.yieldThread()
if len(object_groups) == 0:
Logger.log("w", "No objects suitable for one at a time found, or no correct order found")
else:
temp_list = [] temp_list = []
has_printing_mesh = False
for node in DepthFirstIterator(self._scene.getRoot()):
mesh_data = node.getMeshData()
if node.callDecoration("isSliceable") and mesh_data and mesh_data.getVertices() is not None:
is_non_printing_mesh = bool(node.callDecoration("isNonPrintingMesh"))
# Find a reason not to add the node # Node can't be printed, so don't bother sending it.
if node.callDecoration("getBuildPlateNumber") != self._build_plate_number: if getattr(node, "_outside_buildarea", False):
continue continue
if getattr(node, "_outside_buildarea", False) and not is_non_printing_mesh:
continue
temp_list.append(node) # Filter on current build plate
if not is_non_printing_mesh: build_plate_number = node.callDecoration("getBuildPlateNumber")
has_printing_mesh = True if build_plate_number is not None and build_plate_number != self._build_plate_number:
continue
Job.yieldThread() children = node.getAllChildren()
children.append(node)
# If the list doesn't have any model with suitable settings then clean the list for child_node in children:
# otherwise CuraEngine will crash mesh_data = child_node.getMeshData()
if not has_printing_mesh: if mesh_data and mesh_data.getVertices() is not None:
temp_list.clear() temp_list.append(child_node)
if temp_list: if temp_list:
object_groups.append(temp_list) object_groups.append(temp_list)
Job.yieldThread()
if len(object_groups) == 0:
Logger.log("w", "No objects suitable for one at a time found, or no correct order found")
else:
temp_list = []
has_printing_mesh = False
for node in DepthFirstIterator(self._scene.getRoot()):
mesh_data = node.getMeshData()
if node.callDecoration("isSliceable") and mesh_data and mesh_data.getVertices() is not None:
is_non_printing_mesh = bool(node.callDecoration("isNonPrintingMesh"))
global_stack = CuraApplication.getInstance().getGlobalContainerStack() # Find a reason not to add the node
if not global_stack: if node.callDecoration("getBuildPlateNumber") != self._build_plate_number:
return continue
extruders_enabled = {position: stack.isEnabled for position, stack in global_stack.extruders.items()} if getattr(node, "_outside_buildarea", False) and not is_non_printing_mesh:
filtered_object_groups = []
has_model_with_disabled_extruders = False
associated_disabled_extruders = set()
for group in object_groups:
stack = global_stack
skip_group = False
for node in group:
# Only check if the printing extruder is enabled for printing meshes
is_non_printing_mesh = node.callDecoration("evaluateIsNonPrintingMesh")
extruder_position = node.callDecoration("getActiveExtruderPosition")
if not is_non_printing_mesh and not extruders_enabled[extruder_position]:
skip_group = True
has_model_with_disabled_extruders = True
associated_disabled_extruders.add(extruder_position)
if not skip_group:
filtered_object_groups.append(group)
if has_model_with_disabled_extruders:
self.setResult(StartJobResult.ObjectsWithDisabledExtruder)
associated_disabled_extruders = {str(c) for c in sorted([int(p) + 1 for p in associated_disabled_extruders])}
self.setMessage(", ".join(associated_disabled_extruders))
return
# There are cases when there is nothing to slice. This can happen due to one at a time slicing not being
# able to find a possible sequence or because there are no objects on the build plate (or they are outside
# the build volume)
if not filtered_object_groups:
self.setResult(StartJobResult.NothingToSlice)
return
self._buildGlobalSettingsMessage(stack)
self._buildGlobalInheritsStackMessage(stack)
# Build messages for extruder stacks
for extruder_stack in global_stack.extruderList:
self._buildExtruderMessage(extruder_stack)
for group in filtered_object_groups:
group_message = self._slice_message.addRepeatedMessage("object_lists")
parent = group[0].getParent()
if parent is not None and parent.callDecoration("isGroup"):
self._handlePerObjectSettings(cast(CuraSceneNode, parent), group_message)
for object in group:
mesh_data = object.getMeshData()
if mesh_data is None:
continue continue
rot_scale = object.getWorldTransformation().getTransposed().getData()[0:3, 0:3]
translate = object.getWorldTransformation().getData()[:3, 3]
# This effectively performs a limited form of MeshData.getTransformed that ignores normals. temp_list.append(node)
verts = mesh_data.getVertices() if not is_non_printing_mesh:
verts = verts.dot(rot_scale) has_printing_mesh = True
verts += translate
# Convert from Y up axes to Z up axes. Equals a 90 degree rotation. Job.yieldThread()
verts[:, [1, 2]] = verts[:, [2, 1]]
verts[:, 1] *= -1
obj = group_message.addRepeatedMessage("objects") # If the list doesn't have any model with suitable settings then clean the list
obj.id = id(object) # otherwise CuraEngine will crash
obj.name = object.getName() if not has_printing_mesh:
indices = mesh_data.getIndices() temp_list.clear()
if indices is not None:
flat_verts = numpy.take(verts, indices.flatten(), axis=0)
else:
flat_verts = numpy.array(verts)
obj.vertices = flat_verts if temp_list:
object_groups.append(temp_list)
self._handlePerObjectSettings(cast(CuraSceneNode, object), obj) global_stack = CuraApplication.getInstance().getGlobalContainerStack()
if not global_stack:
return
extruders_enabled = {position: stack.isEnabled for position, stack in global_stack.extruders.items()}
filtered_object_groups = []
has_model_with_disabled_extruders = False
associated_disabled_extruders = set()
for group in object_groups:
stack = global_stack
skip_group = False
for node in group:
# Only check if the printing extruder is enabled for printing meshes
is_non_printing_mesh = node.callDecoration("evaluateIsNonPrintingMesh")
extruder_position = node.callDecoration("getActiveExtruderPosition")
if not is_non_printing_mesh and not extruders_enabled[extruder_position]:
skip_group = True
has_model_with_disabled_extruders = True
associated_disabled_extruders.add(extruder_position)
if not skip_group:
filtered_object_groups.append(group)
Job.yieldThread() if has_model_with_disabled_extruders:
self.setResult(StartJobResult.ObjectsWithDisabledExtruder)
associated_disabled_extruders = {str(c) for c in sorted([int(p) + 1 for p in associated_disabled_extruders])}
self.setMessage(", ".join(associated_disabled_extruders))
return
# There are cases when there is nothing to slice. This can happen due to one at a time slicing not being
# able to find a possible sequence or because there are no objects on the build plate (or they are outside
# the build volume)
if not filtered_object_groups:
self.setResult(StartJobResult.NothingToSlice)
return
self._buildGlobalSettingsMessage(stack)
self._buildGlobalInheritsStackMessage(stack)
# Build messages for extruder stacks
for extruder_stack in global_stack.extruderList:
self._buildExtruderMessage(extruder_stack)
for group in filtered_object_groups:
group_message = self._slice_message.addRepeatedMessage("object_lists")
parent = group[0].getParent()
if parent is not None and parent.callDecoration("isGroup"):
self._handlePerObjectSettings(cast(CuraSceneNode, parent), group_message)
for object in group:
mesh_data = object.getMeshData()
if mesh_data is None:
continue
rot_scale = object.getWorldTransformation().getTransposed().getData()[0:3, 0:3]
translate = object.getWorldTransformation().getData()[:3, 3]
# This effectively performs a limited form of MeshData.getTransformed that ignores normals.
verts = mesh_data.getVertices()
verts = verts.dot(rot_scale)
verts += translate
# Convert from Y up axes to Z up axes. Equals a 90 degree rotation.
verts[:, [1, 2]] = verts[:, [2, 1]]
verts[:, 1] *= -1
obj = group_message.addRepeatedMessage("objects")
obj.id = id(object)
obj.name = object.getName()
indices = mesh_data.getIndices()
if indices is not None:
flat_verts = numpy.take(verts, indices.flatten(), axis=0)
else:
flat_verts = numpy.array(verts)
obj.vertices = flat_verts
self._handlePerObjectSettings(cast(CuraSceneNode, object), obj)
Job.yieldThread()
self.setResult(StartJobResult.Finished) self.setResult(StartJobResult.Finished)
@ -344,10 +343,7 @@ class StartSliceJob(Job):
result["time"] = time.strftime("%H:%M:%S") #Some extra settings. result["time"] = time.strftime("%H:%M:%S") #Some extra settings.
result["date"] = time.strftime("%d-%m-%Y") result["date"] = time.strftime("%d-%m-%Y")
result["day"] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][int(time.strftime("%w"))] result["day"] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][int(time.strftime("%w"))]
result["initial_extruder_nr"] = CuraApplication.getInstance().getExtruderManager().getInitialExtruderNr()
initial_extruder_stack = CuraApplication.getInstance().getExtruderManager().getUsedExtruderStacks()[0]
initial_extruder_nr = initial_extruder_stack.getProperty("extruder_nr", "value")
result["initial_extruder_nr"] = initial_extruder_nr
return result return result

View File

@ -35,7 +35,7 @@ class GCodeStep():
Class to store the current value of each G_Code parameter Class to store the current value of each G_Code parameter
for any G-Code step for any G-Code step
""" """
def __init__(self, step, in_relative_movement: bool = False): def __init__(self, step, in_relative_movement: bool = False) -> None:
self.step = step self.step = step
self.step_x = 0 self.step_x = 0
self.step_y = 0 self.step_y = 0

View File

@ -11,7 +11,7 @@ if TYPE_CHECKING:
class SimulationViewProxy(QObject): class SimulationViewProxy(QObject):
def __init__(self, simulation_view: "SimulationView", parent=None): def __init__(self, simulation_view: "SimulationView", parent=None) -> None:
super().__init__(parent) super().__init__(parent)
self._simulation_view = simulation_view self._simulation_view = simulation_view
self._current_layer = 0 self._current_layer = 0

View File

@ -2,6 +2,7 @@
# Toolbox is released under the terms of the LGPLv3 or higher. # Toolbox is released under the terms of the LGPLv3 or higher.
from .src import Toolbox from .src import Toolbox
from .src.CloudSync.SyncOrchestrator import SyncOrchestrator
def getMetaData(): def getMetaData():
@ -9,4 +10,6 @@ def getMetaData():
def register(app): def register(app):
return {"extension": Toolbox.Toolbox(app)} return {
"extension": [Toolbox.Toolbox(app), SyncOrchestrator(app)]
}

View File

@ -96,17 +96,12 @@ Window
visible: toolbox.restartRequired visible: toolbox.restartRequired
height: visible ? UM.Theme.getSize("toolbox_footer").height : 0 height: visible ? UM.Theme.getSize("toolbox_footer").height : 0
} }
// TODO: Clean this up:
Connections Connections
{ {
target: toolbox target: toolbox
onShowLicenseDialog: onShowLicenseDialog: { licenseDialog.show() }
{ onCloseLicenseDialog: { licenseDialog.close() }
licenseDialog.pluginName = toolbox.getLicenseDialogPluginName();
licenseDialog.licenseContent = toolbox.getLicenseDialogLicenseContent();
licenseDialog.pluginFileLocation = toolbox.getLicenseDialogPluginFileLocation();
licenseDialog.show();
}
} }
ToolboxLicenseDialog ToolboxLicenseDialog

View File

@ -48,13 +48,13 @@ UM.Dialog{
{ {
font: UM.Theme.getFont("default") font: UM.Theme.getFont("default")
text: catalog.i18nc("@label", "The following packages will be added:") text: catalog.i18nc("@label", "The following packages will be added:")
visible: toolbox.has_compatible_packages visible: subscribedPackagesModel.hasCompatiblePackages
color: UM.Theme.getColor("text") color: UM.Theme.getColor("text")
height: contentHeight + UM.Theme.getSize("default_margin").height height: contentHeight + UM.Theme.getSize("default_margin").height
} }
Repeater Repeater
{ {
model: toolbox.subscribedPackagesModel model: subscribedPackagesModel
Component Component
{ {
Item Item
@ -74,7 +74,7 @@ UM.Dialog{
} }
Label Label
{ {
text: model.name text: model.display_name
font: UM.Theme.getFont("medium_bold") font: UM.Theme.getFont("medium_bold")
anchors.left: packageIcon.right anchors.left: packageIcon.right
anchors.leftMargin: UM.Theme.getSize("default_margin").width anchors.leftMargin: UM.Theme.getSize("default_margin").width
@ -91,20 +91,20 @@ UM.Dialog{
{ {
font: UM.Theme.getFont("default") 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 incompatible Cura version:")
visible: toolbox.has_incompatible_packages visible: subscribedPackagesModel.hasIncompatiblePackages
color: UM.Theme.getColor("text") color: UM.Theme.getColor("text")
height: contentHeight + UM.Theme.getSize("default_margin").height height: contentHeight + UM.Theme.getSize("default_margin").height
} }
Repeater Repeater
{ {
model: toolbox.subscribedPackagesModel model: subscribedPackagesModel
Component Component
{ {
Item Item
{ {
width: parent.width width: parent.width
property int lineHeight: 60 property int lineHeight: 60
visible: !model.is_compatible visible: !model.is_compatible && !model.is_dismissed
height: visible ? (lineHeight + UM.Theme.getSize("default_margin").height) : 0 // We only show the incompatible packages here height: visible ? (lineHeight + UM.Theme.getSize("default_margin").height) : 0 // We only show the incompatible packages here
Image Image
{ {
@ -117,7 +117,7 @@ UM.Dialog{
} }
Label Label
{ {
text: model.name text: model.display_name
font: UM.Theme.getFont("medium_bold") font: UM.Theme.getFont("medium_bold")
anchors.left: packageIcon.right anchors.left: packageIcon.right
anchors.leftMargin: UM.Theme.getSize("default_margin").width anchors.leftMargin: UM.Theme.getSize("default_margin").width
@ -125,6 +125,26 @@ UM.Dialog{
color: UM.Theme.getColor("text") color: UM.Theme.getColor("text")
elide: Text.ElideRight 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)
}
}
}
} }
} }
} }
@ -132,13 +152,16 @@ UM.Dialog{
} // End of ScrollView } // End of ScrollView
Cura.ActionButton Cura.PrimaryButton
{ {
id: nextButton id: nextButton
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.right: parent.right anchors.right: parent.right
anchors.margins: UM.Theme.getSize("default_margin").height anchors.margins: UM.Theme.getSize("default_margin").height
text: catalog.i18nc("@button", "Next") text: catalog.i18nc("@button", "Next")
onClicked: accept()
leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width
rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width
} }
} }
} }

View File

@ -10,65 +10,65 @@ import QtQuick.Controls.Styles 1.4
// TODO: Switch to QtQuick.Controls 2.x and remove QtQuick.Controls.Styles // TODO: Switch to QtQuick.Controls 2.x and remove QtQuick.Controls.Styles
import UM 1.1 as UM import UM 1.1 as UM
import Cura 1.6 as Cura
UM.Dialog UM.Dialog
{ {
title: catalog.i18nc("@title:window", "Plugin License Agreement") id: licenseDialog
title: licenseModel.dialogTitle
minimumWidth: UM.Theme.getSize("license_window_minimum").width minimumWidth: UM.Theme.getSize("license_window_minimum").width
minimumHeight: UM.Theme.getSize("license_window_minimum").height minimumHeight: UM.Theme.getSize("license_window_minimum").height
width: minimumWidth width: minimumWidth
height: minimumHeight height: minimumHeight
property var pluginName;
property var licenseContent;
property var pluginFileLocation;
Item Item
{ {
anchors.fill: parent anchors.fill: parent
UM.I18nCatalog{id: catalog; name: "cura"}
Label Label
{ {
id: licenseTitle id: licenseHeader
anchors.top: parent.top anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
text: licenseDialog.pluginName + ": " + 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?") text: licenseModel.headerText
wrapMode: Text.Wrap wrapMode: Text.Wrap
renderType: Text.NativeRendering renderType: Text.NativeRendering
} }
TextArea TextArea
{ {
id: licenseText id: licenseText
anchors.top: licenseTitle.bottom anchors.top: licenseHeader.bottom
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.topMargin: UM.Theme.getSize("default_margin").height anchors.topMargin: UM.Theme.getSize("default_margin").height
readOnly: true readOnly: true
text: licenseDialog.licenseContent || "" text: licenseModel.licenseText
} }
} }
rightButtons: rightButtons:
[ [
Button Cura.PrimaryButton
{ {
id: acceptButton leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width
anchors.margins: UM.Theme.getSize("default_margin").width rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width
text: catalog.i18nc("@action:button", "Accept")
onClicked: text: catalog.i18nc("@button", "Agree")
{ onClicked: { handler.onLicenseAccepted() }
licenseDialog.close(); }
toolbox.install(licenseDialog.pluginFileLocation); ]
toolbox.subscribe(licenseDialog.pluginName);
} leftButtons:
}, [
Button Cura.SecondaryButton
{ {
id: declineButton id: declineButton
anchors.margins: UM.Theme.getSize("default_margin").width text: catalog.i18nc("@button", "Decline and remove from account")
text: catalog.i18nc("@action:button", "Decline") onClicked: { handler.onLicenseDeclined() }
onClicked:
{
licenseDialog.close();
}
} }
] ]
} }

View File

@ -4,7 +4,7 @@
import re import re
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
from PyQt5.QtCore import Qt, pyqtProperty, pyqtSignal from PyQt5.QtCore import Qt, pyqtProperty
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel

View File

@ -0,0 +1,28 @@
from typing import Union
from cura import ApplicationMetadata, UltimakerCloudAuthentication
class CloudApiModel:
sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int]
cloud_api_version = UltimakerCloudAuthentication.CuraCloudAPIVersion # type: str
cloud_api_root = UltimakerCloudAuthentication.CuraCloudAPIRoot # type: str
api_url = "{cloud_api_root}/cura-packages/v{cloud_api_version}/cura/v{sdk_version}".format(
cloud_api_root = cloud_api_root,
cloud_api_version = cloud_api_version,
sdk_version = sdk_version
) # type: str
# https://api.ultimaker.com/cura-packages/v1/user/packages
api_url_user_packages = "{cloud_api_root}/cura-packages/v{cloud_api_version}/user/packages".format(
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,110 @@
import json
from typing import Optional
from PyQt5.QtCore import QObject
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
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 ..CloudApiModel import CloudApiModel
from .SubscribedPackagesModel import SubscribedPackagesModel
from ..UltimakerCloudScope import UltimakerCloudScope
class CloudPackageChecker(QObject):
def __init__(self, application: CuraApplication) -> None:
super().__init__()
self.discrepancies = Signal() # Emits SubscribedPackagesModel
self._application = application # type: CuraApplication
self._scope = UltimakerCloudScope(application)
self._model = SubscribedPackagesModel()
self._application.initializationFinished.connect(self._onAppInitialized)
self._i18n_catalog = i18nCatalog("cura")
# 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()
# check again whenever the login state changes
self._application.getCuraAPI().account.loginStateChanged.connect(self._fetchUserSubscribedPackages)
def _fetchUserSubscribedPackages(self) -> None:
if self._application.getCuraAPI().account.isLoggedIn:
self._getUserPackages()
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.")
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:
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",
reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url())
return
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")

View File

@ -0,0 +1,23 @@
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
)
def unsubscribe(self, package_id: str) -> None:
url = CloudApiModel.userPackageUrl(package_id)
self._request_manager.delete(url=url, scope=self._scope)

View File

@ -0,0 +1,40 @@
import os
from typing import Optional
from PyQt5.QtCore import QObject, pyqtSlot
from UM.Qt.QtApplication import QtApplication
from UM.Signal import Signal
from .SubscribedPackagesModel import SubscribedPackagesModel
## Shows a list of packages to be added or removed. The user can select which packages to (un)install. The user's
# choices are emitted on the `packageMutations` Signal.
class DiscrepanciesPresenter(QObject):
def __init__(self, app: QtApplication) -> None:
super().__init__(app)
self.packageMutations = Signal() # Emits SubscribedPackagesModel
self._app = app
self._package_manager = app.getPackageManager()
self._dialog = None # type: Optional[QObject]
self._compatibility_dialog_path = "resources/qml/dialogs/CompatibilityDialog.qml"
def present(self, plugin_path: str, model: SubscribedPackagesModel) -> None:
path = os.path.join(plugin_path, self._compatibility_dialog_path)
self._dialog = self._app.createQmlComponent(path, {"subscribedPackagesModel": model, "handler": self})
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:
# 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)

View File

@ -0,0 +1,136 @@
import tempfile
from typing import Dict, List, Any
from PyQt5.QtNetwork import QNetworkReply
from UM import i18n_catalog
from UM.Logger import Logger
from UM.Message import Message
from UM.Signal import Signal
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from cura.CuraApplication import CuraApplication
from .SubscribedPackagesModel import SubscribedPackagesModel
from ..UltimakerCloudScope import UltimakerCloudScope
## Downloads a set of packages from the Ultimaker Cloud Marketplace
# use download() exactly once: should not be used for multiple sets of downloads since this class contains state
class DownloadPresenter:
DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB
def __init__(self, app: CuraApplication) -> None:
# Emits (Dict[str, str], List[str]) # (success_items, error_items)
# Dict{success_package_id, temp_file_path}
# List[errored_package_id]
self.done = Signal()
self._app = app
self._scope = UltimakerCloudScope(app)
self._started = False
self._progress_message = self._createProgressMessage()
self._progress = {} # type: Dict[str, Dict[str, Any]] # package_id, Dict
self._error = [] # type: List[str] # package_id
def download(self, model: SubscribedPackagesModel) -> None:
if self._started:
Logger.error("Download already started. Create a new %s instead", self.__class__.__name__)
return
manager = HttpRequestManager.getInstance()
for item in model.items:
package_id = item["package_id"]
def finishedCallback(reply: QNetworkReply, pid = package_id) -> None:
self._onFinished(pid, reply)
def progressCallback(rx: int, rt: int, pid = package_id) -> None:
self._onProgress(pid, rx, rt)
def errorCallback(reply: QNetworkReply, error: QNetworkReply.NetworkError, pid = package_id) -> None:
self._onError(pid)
request_data = manager.get(
item["download_url"],
callback = finishedCallback,
download_progress_callback = progressCallback,
error_callback = errorCallback,
scope = self._scope)
self._progress[package_id] = {
"received": 0,
"total": 1, # make sure this is not considered done yet. Also divByZero-safe
"file_written": None,
"request_data": request_data
}
self._started = True
self._progress_message.show()
def abort(self) -> None:
manager = HttpRequestManager.getInstance()
for item in self._progress.values():
manager.abortRequest(item["request_data"])
# Aborts all current operations and returns a copy with the same settings such as app and scope
def resetCopy(self) -> "DownloadPresenter":
self.abort()
self.done.disconnectAll()
return DownloadPresenter(self._app)
def _createProgressMessage(self) -> Message:
return Message(i18n_catalog.i18nc(
"@info:generic",
"\nSyncing..."),
lifetime = 0,
use_inactivity_timer=False,
progress = 0.0,
title = i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", ))
def _onFinished(self, package_id: str, reply: QNetworkReply) -> None:
self._progress[package_id]["received"] = self._progress[package_id]["total"]
try:
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)
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
self._app.processEvents()
self._progress[package_id]["file_written"] = temp_file.name
except IOError as e:
Logger.logException("e", "Failed to write downloaded package to temp file", e)
self._onError(package_id)
temp_file.close()
self._checkDone()
def _onProgress(self, package_id: str, rx: int, rt: int) -> None:
self._progress[package_id]["received"] = rx
self._progress[package_id]["total"] = rt
received = 0
total = 0
for item in self._progress.values():
received += item["received"]
total += item["total"]
self._progress_message.setProgress(100.0 * (received / total)) # [0 .. 100] %
def _onError(self, package_id: str) -> None:
self._progress.pop(package_id)
self._error.append(package_id)
self._checkDone()
def _checkDone(self) -> bool:
for item in self._progress.values():
if not item["file_written"]:
return False
success_items = {package_id : value["file_written"] for package_id, value in self._progress.items()}
error_items = [package_id for package_id in self._error]
self._progress_message.hide()
self.done.emit(success_items, error_items)
return True

View File

@ -0,0 +1,55 @@
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
# Model for the ToolboxLicenseDialog
class LicenseModel(QObject):
dialogTitleChanged = pyqtSignal()
headerChanged = pyqtSignal()
licenseTextChanged = pyqtSignal()
def __init__(self) -> None:
super().__init__()
self._current_page_idx = 0
self._page_count = 1
self._dialogTitle = ""
self._header_text = ""
self._license_text = ""
self._package_name = ""
@pyqtProperty(str, notify=dialogTitleChanged)
def dialogTitle(self) -> str:
return self._dialogTitle
@pyqtProperty(str, notify=headerChanged)
def headerText(self) -> str:
return self._header_text
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()
@pyqtProperty(str, notify=licenseTextChanged)
def licenseText(self) -> str:
return self._license_text
def setLicenseText(self, license_text: str) -> None:
if self._license_text != license_text:
self._license_text = license_text
self.licenseTextChanged.emit()
def setCurrentPageIdx(self, idx: int) -> None:
self._current_page_idx = idx
self._updateDialogTitle()
def setPageCount(self, count: int) -> None:
self._page_count = count
self._updateDialogTitle()
def _updateDialogTitle(self):
self._dialogTitle = catalog.i18nc("@title:window", "Plugin License Agreement ({}/{})"
.format(self._current_page_idx + 1, self._page_count))
self.dialogTitleChanged.emit()

View File

@ -0,0 +1,97 @@
import os
from typing import Dict, Optional, List
from PyQt5.QtCore import QObject, pyqtSlot
from UM.PackageManager import PackageManager
from UM.Signal import Signal
from cura.CuraApplication import CuraApplication
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):
def __init__(self, app: CuraApplication) -> None:
super().__init__()
self._dialog = None # type: Optional[QObject]
self._package_manager = app.getPackageManager() # type: PackageManager
# Emits List[Dict[str, [Any]] containing for example
# [{ "package_id": "BarbarianPlugin", "package_path" : "/tmp/dg345as", "accepted" : True }]
self.licenseAnswers = Signal()
self._current_package_idx = 0
self._package_models = [] # type: List[Dict]
self._license_model = LicenseModel() # type: LicenseModel
self._app = app
self._compatibility_dialog_path = "resources/qml/dialogs/ToolboxLicenseDialog.qml"
## 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:
path = os.path.join(plugin_path, self._compatibility_dialog_path)
self._initState(packages)
if self._dialog is None:
context_properties = {
"catalog": i18nCatalog("cura"),
"licenseModel": self._license_model,
"handler": self
}
self._dialog = self._app.createQmlComponent(path, context_properties)
self._license_model.setPageCount(len(self._package_models))
self._presentCurrentPackage()
@pyqtSlot()
def onLicenseAccepted(self) -> None:
self._package_models[self._current_package_idx]["accepted"] = True
self._checkNextPage()
@pyqtSlot()
def onLicenseDeclined(self) -> None:
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 _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
self._license_model.setCurrentPageIdx(self._current_package_idx)
self._license_model.setPackageName(package_model["package_id"])
self._license_model.setLicenseText(license_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):
self._current_package_idx += 1
self._presentCurrentPackage()
else:
if self._dialog:
self._dialog.close()
self.licenseAnswers.emit(self._package_models)

View File

@ -0,0 +1,31 @@
from UM import i18nCatalog
from UM.Message import Message
from cura.CuraApplication import CuraApplication
## Presents a dialog telling the user that a restart is required to apply changes
# Since we cannot restart Cura, the app is closed instead when the button is clicked
class RestartApplicationPresenter:
def __init__(self, app: CuraApplication) -> None:
self._app = app
self._i18n_catalog = i18nCatalog("cura")
def present(self) -> None:
app_name = self._app.getApplicationDisplayName()
message = Message(self._i18n_catalog.i18nc(
"@info:generic",
"You need to quit and restart {} before changes have effect.", app_name
))
message.addAction("quit",
name="Quit " + app_name,
icon = "",
description="Close the application",
button_align=Message.ActionButtonAlignment.ALIGN_RIGHT)
message.actionTriggered.connect(self._quitClicked)
message.show()
def _quitClicked(self, *_):
self._app.windowClosed()

View File

@ -0,0 +1,82 @@
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt, pyqtProperty, pyqtSlot
from UM.Qt.ListModel import ListModel
from cura import ApplicationMetadata
from UM.Logger import Logger
from typing import List, Dict, Any
class SubscribedPackagesModel(ListModel):
def __init__(self, parent = None):
super().__init__(parent)
self._items = []
self._metadata = None
self._discrepancies = None
self._sdk_version = ApplicationMetadata.CuraSDKVersion
self.addRoleName(Qt.UserRole + 1, "package_id")
self.addRoleName(Qt.UserRole + 2, "display_name")
self.addRoleName(Qt.UserRole + 3, "icon_url")
self.addRoleName(Qt.UserRole + 4, "is_compatible")
self.addRoleName(Qt.UserRole + 5, "is_dismissed")
@pyqtProperty(bool, constant=True)
def hasCompatiblePackages(self) -> bool:
for item in self._items:
if item['is_compatible']:
return True
return False
@pyqtProperty(bool, constant=True)
def hasIncompatiblePackages(self) -> bool:
for item in self._items:
if not item['is_compatible']:
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 initialize(self) -> None:
self._items.clear()
for item in self._metadata:
if item["package_id"] not in self._discrepancies:
continue
package = {
"package_id": item["package_id"],
"display_name": item["display_name"],
"sdk_versions": item["sdk_versions"],
"download_url": item["download_url"],
"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})
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

@ -0,0 +1,102 @@
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 .DiscrepanciesPresenter import DiscrepanciesPresenter
from .DownloadPresenter import DownloadPresenter
from .LicensePresenter import LicensePresenter
from .RestartApplicationPresenter import RestartApplicationPresenter
from .SubscribedPackagesModel import SubscribedPackagesModel
## Orchestrates the synchronizing of packages from the user account to the installed packages
# Example flow:
# - CloudPackageChecker compares a list of packages the user `subscribed` to in their account
# If there are `discrepancies` between the account and locally installed packages, they are emitted
# - DiscrepanciesPresenter shows a list of packages to be added or removed to the user. It emits the `packageMutations`
# the user selected to be performed
# - The SyncOrchestrator uses PackageManager to remove local packages the users wants to see removed
# - 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 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):
def __init__(self, app: CuraApplication) -> None:
super().__init__()
# Differentiate This PluginObject from the Toolbox. self.getId() includes _name.
# getPluginId() will return the same value for The toolbox extension and this one
self._name = "SyncOrchestrator"
self._package_manager = app.getPackageManager()
self._cloud_package_manager = CloudPackageManager(app)
self._checker = CloudPackageChecker(app) # type: CloudPackageChecker
self._checker.discrepancies.connect(self._onDiscrepancies)
self._discrepancies_presenter = DiscrepanciesPresenter(app) # type: DiscrepanciesPresenter
self._discrepancies_presenter.packageMutations.connect(self._onPackageMutations)
self._download_presenter = DownloadPresenter(app) # type: DownloadPresenter
self._license_presenter = LicensePresenter(app) # type: LicensePresenter
self._license_presenter.licenseAnswers.connect(self._onLicenseAnswers)
self._restart_presenter = RestartApplicationPresenter(app)
def _onDiscrepancies(self, model: SubscribedPackagesModel) -> None:
plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath(self.getPluginId()))
self._discrepancies_presenter.present(plugin_path, model)
def _onPackageMutations(self, mutations: SubscribedPackagesModel) -> None:
self._download_presenter = self._download_presenter.resetCopy()
self._download_presenter.done.connect(self._onDownloadFinished)
self._download_presenter.download(mutations)
## Called when a set of packages have finished downloading
# \param success_items: Dict[package_id, file_path]
# \param error_items: List[package_id]
def _onDownloadFinished(self, success_items: 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.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"]):
message = "Could not install {}".format(item["package_id"])
self._showErrorMessage(message)
continue
self._cloud_package_manager.subscribe(item["package_id"])
has_changes = True
else:
self._cloud_package_manager.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

@ -1,7 +1,8 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt, pyqtProperty from PyQt5.QtCore import Qt
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel

View File

@ -1,61 +0,0 @@
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt
from UM.Qt.ListModel import ListModel
from cura import ApplicationMetadata
class SubscribedPackagesModel(ListModel):
def __init__(self, parent = None):
super().__init__(parent)
self._items = []
self._metadata = None
self._discrepancies = None
self._sdk_version = ApplicationMetadata.CuraSDKVersion
self.addRoleName(Qt.UserRole + 1, "name")
self.addRoleName(Qt.UserRole + 2, "icon_url")
self.addRoleName(Qt.UserRole + 3, "is_compatible")
def setMetadata(self, data):
if self._metadata != data:
self._metadata = data
def addValue(self, discrepancy):
if self._discrepancies != discrepancy:
self._discrepancies = discrepancy
def update(self):
self._items.clear()
for item in self._metadata:
if item["package_id"] not in self._discrepancies:
continue
package = {"name": item["display_name"], "sdk_versions": item["sdk_versions"]}
if self._sdk_version not in item["sdk_versions"]:
package.update({"is_compatible": False})
else:
package.update({"is_compatible": True})
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)
def hasCompatiblePackages(self) -> bool:
has_compatible_items = False
for item in self._items:
if item['is_compatible'] == True:
has_compatible_items = True
return has_compatible_items
def hasIncompatiblePackages(self) -> bool:
has_incompatible_items = False
for item in self._items:
if item['is_compatible'] == False:
has_incompatible_items = True
return has_incompatible_items

View File

@ -4,7 +4,6 @@
import json import json
import os import os
import tempfile import tempfile
import platform
from typing import cast, Any, Dict, List, Set, TYPE_CHECKING, Tuple, Optional, Union from typing import cast, Any, Dict, List, Set, TYPE_CHECKING, Tuple, Optional, Union
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot
@ -15,16 +14,17 @@ from UM.PluginRegistry import PluginRegistry
from UM.Extension import Extension from UM.Extension import Extension
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from UM.Version import Version from UM.Version import Version
from UM.Message import Message
from cura import ApplicationMetadata from cura import ApplicationMetadata
from cura import UltimakerCloudAuthentication
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from cura.Machines.ContainerTree import ContainerTree from cura.Machines.ContainerTree import ContainerTree
from .CloudApiModel import CloudApiModel
from .AuthorsModel import AuthorsModel from .AuthorsModel import AuthorsModel
from .CloudSync.CloudPackageManager import CloudPackageManager
from .CloudSync.LicenseModel import LicenseModel
from .PackagesModel import PackagesModel from .PackagesModel import PackagesModel
from .SubscribedPackagesModel import SubscribedPackagesModel from .UltimakerCloudScope import UltimakerCloudScope
if TYPE_CHECKING: if TYPE_CHECKING:
from UM.TaskManagement.HttpRequestData import HttpRequestData from UM.TaskManagement.HttpRequestData import HttpRequestData
@ -32,8 +32,9 @@ if TYPE_CHECKING:
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")
# todo Remove license and download dialog, use SyncOrchestrator instead
## The Toolbox class is responsible of communicating with the server through the API ## Provides a marketplace for users to download plugins an materials
class Toolbox(QObject, Extension): class Toolbox(QObject, Extension):
def __init__(self, application: CuraApplication) -> None: def __init__(self, application: CuraApplication) -> None:
super().__init__() super().__init__()
@ -41,16 +42,13 @@ class Toolbox(QObject, Extension):
self._application = application # type: CuraApplication self._application = application # type: CuraApplication
self._sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int] self._sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int]
self._cloud_api_version = UltimakerCloudAuthentication.CuraCloudAPIVersion # type: str
self._cloud_api_root = UltimakerCloudAuthentication.CuraCloudAPIRoot # type: str
self._api_url = None # type: Optional[str]
# Network: # Network:
self._cloud_package_manager = CloudPackageManager(application) # type: CloudPackageManager
self._download_request_data = None # type: Optional[HttpRequestData] self._download_request_data = None # type: Optional[HttpRequestData]
self._download_progress = 0 # type: float self._download_progress = 0 # type: float
self._is_downloading = False # type: bool self._is_downloading = False # type: bool
self._request_headers = dict() # type: Dict[str, str] self._scope = UltimakerCloudScope(application) # type: UltimakerCloudScope
self._updateRequestHeader()
self._request_urls = {} # type: Dict[str, str] self._request_urls = {} # type: Dict[str, str]
self._to_update = [] # type: List[str] # Package_ids that are waiting to be updated self._to_update = [] # type: List[str] # Package_ids that are waiting to be updated
@ -61,17 +59,15 @@ class Toolbox(QObject, Extension):
self._server_response_data = { self._server_response_data = {
"authors": [], "authors": [],
"packages": [], "packages": [],
"updates": [], "updates": []
"subscribed_packages": [],
} # type: Dict[str, List[Any]] } # type: Dict[str, List[Any]]
# Models: # Models:
self._models = { self._models = {
"authors": AuthorsModel(self), "authors": AuthorsModel(self),
"packages": PackagesModel(self), "packages": PackagesModel(self),
"updates": PackagesModel(self), "updates": PackagesModel(self)
"subscribed_packages": SubscribedPackagesModel(self), } # type: Dict[str, Union[AuthorsModel, PackagesModel]]
} # type: Dict[str, Union[AuthorsModel, PackagesModel, SubscribedPackagesModel]]
self._plugins_showcase_model = PackagesModel(self) self._plugins_showcase_model = PackagesModel(self)
self._plugins_available_model = PackagesModel(self) self._plugins_available_model = PackagesModel(self)
@ -82,6 +78,8 @@ class Toolbox(QObject, Extension):
self._materials_installed_model = PackagesModel(self) self._materials_installed_model = PackagesModel(self)
self._materials_generic_model = PackagesModel(self) self._materials_generic_model = PackagesModel(self)
self._license_model = LicenseModel()
# These properties are for keeping track of the UI state: # These properties are for keeping track of the UI state:
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# View category defines which filter to use, and therefore effectively # View category defines which filter to use, and therefore effectively
@ -104,13 +102,9 @@ class Toolbox(QObject, Extension):
self._restart_required = False # type: bool self._restart_required = False # type: bool
# variables for the license agreement dialog # variables for the license agreement dialog
self._license_dialog_plugin_name = "" # type: str
self._license_dialog_license_content = "" # type: str
self._license_dialog_plugin_file_location = "" # type: str self._license_dialog_plugin_file_location = "" # type: str
self._restart_dialog_message = "" # type: str
self._application.initializationFinished.connect(self._onAppInitialized) self._application.initializationFinished.connect(self._onAppInitialized)
self._application.getCuraAPI().account.accessTokenChanged.connect(self._updateRequestHeader)
# Signals: # Signals:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@ -128,11 +122,11 @@ class Toolbox(QObject, Extension):
filterChanged = pyqtSignal() filterChanged = pyqtSignal()
metadataChanged = pyqtSignal() metadataChanged = pyqtSignal()
showLicenseDialog = pyqtSignal() showLicenseDialog = pyqtSignal()
closeLicenseDialog = pyqtSignal()
uninstallVariablesChanged = pyqtSignal() uninstallVariablesChanged = pyqtSignal()
## Go back to the start state (welcome screen or loading if no login required) ## Go back to the start state (welcome screen or loading if no login required)
def _restart(self): def _restart(self):
self._updateRequestHeader()
# For an Essentials build, login is mandatory # For an Essentials build, login is mandatory
if not self._application.getCuraAPI().account.isLoggedIn and ApplicationMetadata.IsEnterpriseVersion: if not self._application.getCuraAPI().account.isLoggedIn and ApplicationMetadata.IsEnterpriseVersion:
self.setViewPage("welcome") self.setViewPage("welcome")
@ -140,17 +134,6 @@ class Toolbox(QObject, Extension):
self.setViewPage("loading") self.setViewPage("loading")
self._fetchPackageData() self._fetchPackageData()
def _updateRequestHeader(self):
self._request_headers = {
"User-Agent": "%s/%s (%s %s)" % (self._application.getApplicationName(),
self._application.getVersion(),
platform.system(),
platform.machine())
}
access_token = self._application.getCuraAPI().account.accessToken
if access_token:
self._request_headers["Authorization"] = "Bearer {}".format(access_token)
def _resetUninstallVariables(self) -> None: def _resetUninstallVariables(self) -> None:
self._package_id_to_uninstall = None # type: Optional[str] self._package_id_to_uninstall = None # type: Optional[str]
self._package_name_to_uninstall = "" self._package_name_to_uninstall = ""
@ -159,35 +142,25 @@ class Toolbox(QObject, Extension):
@pyqtSlot(str, int) @pyqtSlot(str, int)
def ratePackage(self, package_id: str, rating: int) -> None: def ratePackage(self, package_id: str, rating: int) -> None:
url = "{base_url}/packages/{package_id}/ratings".format(base_url = self._api_url, package_id = package_id) url = "{base_url}/packages/{package_id}/ratings".format(base_url = CloudApiModel.api_url, package_id = package_id)
data = "{\"data\": {\"cura_version\": \"%s\", \"rating\": %i}}" % (Version(self._application.getVersion()), rating) data = "{\"data\": {\"cura_version\": \"%s\", \"rating\": %i}}" % (Version(self._application.getVersion()), rating)
self._application.getHttpRequestManager().put(url, headers_dict = self._request_headers, self._application.getHttpRequestManager().put(url, data = data.encode(), scope = self._scope)
data = data.encode())
@pyqtSlot(str) @pyqtSlot(str)
def subscribe(self, package_id: str) -> None: def subscribe(self, package_id: str) -> None:
if self._application.getCuraAPI().account.isLoggedIn: self._cloud_package_manager.subscribe(package_id)
data = "{\"data\": {\"package_id\": \"%s\", \"sdk_version\": \"%s\"}}" % (package_id, self._sdk_version)
self._application.getHttpRequestManager().put(url=self._api_url_user_packages,
headers_dict=self._request_headers,
data=data.encode()
)
@pyqtSlot(result = str)
def getLicenseDialogPluginName(self) -> str:
return self._license_dialog_plugin_name
@pyqtSlot(result = str)
def getLicenseDialogPluginFileLocation(self) -> str: def getLicenseDialogPluginFileLocation(self) -> str:
return self._license_dialog_plugin_file_location return self._license_dialog_plugin_file_location
@pyqtSlot(result = str)
def getLicenseDialogLicenseContent(self) -> str:
return self._license_dialog_license_content
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) -> None:
self._license_dialog_plugin_name = plugin_name # Set page 1/1 when opening the dialog for a single package
self._license_dialog_license_content = license_content self._license_model.setCurrentPageIdx(0)
self._license_model.setPageCount(1)
self._license_model.setPackageName(plugin_name)
self._license_model.setLicenseText(license_content)
self._license_dialog_plugin_file_location = plugin_file_location self._license_dialog_plugin_file_location = plugin_file_location
self.showLicenseDialog.emit() self.showLicenseDialog.emit()
@ -196,16 +169,6 @@ class Toolbox(QObject, Extension):
def _onAppInitialized(self) -> None: def _onAppInitialized(self) -> None:
self._plugin_registry = self._application.getPluginRegistry() self._plugin_registry = self._application.getPluginRegistry()
self._package_manager = self._application.getPackageManager() self._package_manager = self._application.getPackageManager()
self._api_url = "{cloud_api_root}/cura-packages/v{cloud_api_version}/cura/v{sdk_version}".format(
cloud_api_root = self._cloud_api_root,
cloud_api_version = self._cloud_api_version,
sdk_version = self._sdk_version
)
# https://api.ultimaker.com/cura-packages/v1/user/packages
self._api_url_user_packages = "{cloud_api_root}/cura-packages/v{cloud_api_version}/user/packages".format(
cloud_api_root = self._cloud_api_root,
cloud_api_version = self._cloud_api_version,
)
# We need to construct a query like installed_packages=ID:VERSION&installed_packages=ID:VERSION, etc. # We need to construct a query like installed_packages=ID:VERSION&installed_packages=ID:VERSION, etc.
installed_package_ids_with_versions = [":".join(items) for items in installed_package_ids_with_versions = [":".join(items) for items in
@ -213,27 +176,20 @@ class Toolbox(QObject, Extension):
installed_packages_query = "&installed_packages=".join(installed_package_ids_with_versions) installed_packages_query = "&installed_packages=".join(installed_package_ids_with_versions)
self._request_urls = { self._request_urls = {
"authors": "{base_url}/authors".format(base_url = self._api_url), "authors": "{base_url}/authors".format(base_url = CloudApiModel.api_url),
"packages": "{base_url}/packages".format(base_url = self._api_url), "packages": "{base_url}/packages".format(base_url = CloudApiModel.api_url),
"updates": "{base_url}/packages/package-updates?installed_packages={query}".format( "updates": "{base_url}/packages/package-updates?installed_packages={query}".format(
base_url = self._api_url, query = installed_packages_query), base_url = CloudApiModel.api_url, query = installed_packages_query)
"subscribed_packages": self._api_url_user_packages,
} }
self._application.getCuraAPI().account.loginStateChanged.connect(self._restart) self._application.getCuraAPI().account.loginStateChanged.connect(self._restart)
self._application.getCuraAPI().account.loginStateChanged.connect(self._fetchUserSubscribedPackages)
# On boot we check which packages have updates. # On boot we check which packages have updates.
if CuraApplication.getInstance().getPreferences().getValue("info/automatic_update_check") and len(installed_package_ids_with_versions) > 0: if CuraApplication.getInstance().getPreferences().getValue("info/automatic_update_check") and len(installed_package_ids_with_versions) > 0:
# Request the latest and greatest! # Request the latest and greatest!
self._makeRequestByType("updates") self._makeRequestByType("updates")
self._fetchUserSubscribedPackages()
def _fetchUserSubscribedPackages(self):
if self._application.getCuraAPI().account.isLoggedIn:
self._makeRequestByType("subscribed_packages")
def _fetchPackageData(self) -> None: def _fetchPackageData(self) -> None:
self._makeRequestByType("packages") self._makeRequestByType("packages")
self._makeRequestByType("authors") self._makeRequestByType("authors")
@ -262,7 +218,11 @@ class Toolbox(QObject, Extension):
return None return None
path = os.path.join(plugin_path, "resources", "qml", qml_name) path = os.path.join(plugin_path, "resources", "qml", qml_name)
dialog = self._application.createQmlComponent(path, {"toolbox": self}) dialog = self._application.createQmlComponent(path, {
"toolbox": self,
"handler": self,
"licenseModel": self._license_model
})
if not dialog: if not dialog:
raise Exception("Failed to create Marketplace dialog") raise Exception("Failed to create Marketplace dialog")
return dialog return dialog
@ -333,13 +293,14 @@ class Toolbox(QObject, Extension):
self.metadataChanged.emit() self.metadataChanged.emit()
@pyqtSlot(str) @pyqtSlot(str)
def install(self, file_path: str) -> None: def install(self, file_path: str) -> Optional[str]:
self._package_manager.installPackage(file_path) package_id = self._package_manager.installPackage(file_path)
self.installChanged.emit() self.installChanged.emit()
self._updateInstalledModels() self._updateInstalledModels()
self.metadataChanged.emit() self.metadataChanged.emit()
self._restart_required = True self._restart_required = True
self.restartRequiredChanged.emit() self.restartRequiredChanged.emit()
return package_id
## Check package usage and uninstall ## Check package usage and uninstall
# If the package is in use, you'll get a confirmation dialog to set everything to default # If the package is in use, you'll get a confirmation dialog to set everything to default
@ -411,6 +372,17 @@ class Toolbox(QObject, Extension):
self._resetUninstallVariables() self._resetUninstallVariables()
self.closeConfirmResetDialog() self.closeConfirmResetDialog()
@pyqtSlot()
def onLicenseAccepted(self):
self.closeLicenseDialog.emit()
package_id = self.install(self.getLicenseDialogPluginFileLocation())
self.subscribe(package_id)
@pyqtSlot()
def onLicenseDeclined(self):
self.closeLicenseDialog.emit()
def _markPackageMaterialsAsToBeUninstalled(self, package_id: str) -> None: def _markPackageMaterialsAsToBeUninstalled(self, package_id: str) -> None:
container_registry = self._application.getContainerRegistry() container_registry = self._application.getContainerRegistry()
@ -560,15 +532,14 @@ class Toolbox(QObject, Extension):
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def _makeRequestByType(self, request_type: str) -> None: def _makeRequestByType(self, request_type: str) -> None:
Logger.log("d", "Requesting [%s] metadata from server.", request_type) Logger.log("d", "Requesting [%s] metadata from server.", request_type)
self._updateRequestHeader()
url = self._request_urls[request_type] url = self._request_urls[request_type]
callback = lambda r, rt = request_type: self._onDataRequestFinished(rt, r) callback = lambda r, rt = request_type: self._onDataRequestFinished(rt, r)
error_callback = lambda r, e, rt = request_type: self._onDataRequestError(rt, r, e) error_callback = lambda r, e, rt = request_type: self._onDataRequestError(rt, r, e)
self._application.getHttpRequestManager().get(url, self._application.getHttpRequestManager().get(url,
headers_dict = self._request_headers,
callback = callback, callback = callback,
error_callback = error_callback) error_callback = error_callback,
scope=self._scope)
@pyqtSlot(str) @pyqtSlot(str)
def startDownload(self, url: str) -> None: def startDownload(self, url: str) -> None:
@ -577,10 +548,12 @@ class Toolbox(QObject, Extension):
callback = lambda r: self._onDownloadFinished(r) callback = lambda r: self._onDownloadFinished(r)
error_callback = lambda r, e: self._onDownloadFailed(r, e) error_callback = lambda r, e: self._onDownloadFailed(r, e)
download_progress_callback = self._onDownloadProgress download_progress_callback = self._onDownloadProgress
request_data = self._application.getHttpRequestManager().get(url, headers_dict = self._request_headers, request_data = self._application.getHttpRequestManager().get(url,
callback = callback, callback = callback,
error_callback = error_callback, error_callback = error_callback,
download_progress_callback = download_progress_callback) download_progress_callback = download_progress_callback,
scope=self._scope
)
self._download_request_data = request_data self._download_request_data = request_data
self.setDownloadProgress(0) self.setDownloadProgress(0)
@ -652,46 +625,12 @@ class Toolbox(QObject, Extension):
# Tell the package manager that there's a new set of updates available. # Tell the package manager that there's a new set of updates available.
packages = set([pkg["package_id"] for pkg in self._server_response_data[request_type]]) packages = set([pkg["package_id"] for pkg in self._server_response_data[request_type]])
self._package_manager.setPackagesWithUpdate(packages) self._package_manager.setPackagesWithUpdate(packages)
elif request_type == "subscribed_packages":
self._checkCompatibilities(json_data["data"])
self.metadataChanged.emit() self.metadataChanged.emit()
if self.isLoadingComplete(): if self.isLoadingComplete():
self.setViewPage("overview") self.setViewPage("overview")
def _checkCompatibilities(self, json_data) -> None:
user_subscribed_packages = [plugin["package_id"] for plugin in json_data]
user_installed_packages = self._package_manager.getUserInstalledPackages()
# We check if there are packages installed in Cloud Marketplace but not in Cura marketplace (discrepancy)
package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages))
if package_discrepancy:
self._models["subscribed_packages"].addValue(package_discrepancy)
self._models["subscribed_packages"].update()
Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages")
sync_message = Message(i18n_catalog.i18nc(
"@info:generic",
"\nDo you want to sync material and software packages with your account?"),
lifetime=0,
title=i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", ))
sync_message.addAction("sync",
name=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()
compatibility_dialog_path = "resources/qml/dialogs/CompatibilityDialog.qml"
plugin_path_prefix = PluginRegistry.getInstance().getPluginPath(self.getPluginId())
if plugin_path_prefix:
path = os.path.join(plugin_path_prefix, compatibility_dialog_path)
self.compatibility_dialog_view = self._application.getInstance().createQmlComponent(path, {"toolbox": self})
# This function goes through all known remote versions of a package and notifies the package manager of this change # This function goes through all known remote versions of a package and notifies the package manager of this change
def _notifyPackageManager(self): def _notifyPackageManager(self):
for package in self._server_response_data["packages"]: for package in self._server_response_data["packages"]:
@ -735,8 +674,10 @@ class Toolbox(QObject, Extension):
self.openLicenseDialog(package_info["package_id"], license_content, file_path) self.openLicenseDialog(package_info["package_id"], license_content, file_path)
return return
self.install(file_path) package_id = self.install(file_path)
self.subscribe(package_info["package_id"]) 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)
# Getter & Setters for Properties: # Getter & Setters for Properties:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@ -773,6 +714,11 @@ class Toolbox(QObject, Extension):
self._view_category = category self._view_category = category
self.viewChanged.emit() self.viewChanged.emit()
## Function explicitly defined so that it can be called through the callExtensionsMethod
# which cannot receive arguments.
def setViewCategoryToMaterials(self) -> None:
self.setViewCategory("material")
@pyqtProperty(str, fset = setViewCategory, notify = viewChanged) @pyqtProperty(str, fset = setViewCategory, notify = viewChanged)
def viewCategory(self) -> str: def viewCategory(self) -> str:
return self._view_category return self._view_category
@ -792,18 +738,6 @@ class Toolbox(QObject, Extension):
def authorsModel(self) -> AuthorsModel: def authorsModel(self) -> AuthorsModel:
return cast(AuthorsModel, self._models["authors"]) return cast(AuthorsModel, self._models["authors"])
@pyqtProperty(QObject, constant = True)
def subscribedPackagesModel(self) -> SubscribedPackagesModel:
return cast(SubscribedPackagesModel, self._models["subscribed_packages"])
@pyqtProperty(bool, constant=True)
def has_compatible_packages(self) -> bool:
return self._models["subscribed_packages"].hasCompatiblePackages()
@pyqtProperty(bool, constant=True)
def has_incompatible_packages(self) -> bool:
return self._models["subscribed_packages"].hasIncompatiblePackages()
@pyqtProperty(QObject, constant = True) @pyqtProperty(QObject, constant = True)
def packagesModel(self) -> PackagesModel: def packagesModel(self) -> PackagesModel:
return cast(PackagesModel, self._models["packages"]) return cast(PackagesModel, self._models["packages"])

View File

@ -0,0 +1,25 @@
from PyQt5.QtNetwork import QNetworkRequest
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestScope import DefaultUserAgentScope
from cura.API import Account
from cura.CuraApplication import CuraApplication
class UltimakerCloudScope(DefaultUserAgentScope):
def __init__(self, application: CuraApplication):
super().__init__(application)
api = application.getCuraAPI()
self._account = api.account # type: Account
def request_hook(self, request: QNetworkRequest):
super().request_hook(request)
token = self._account.accessToken
if not self._account.isLoggedIn or token is None:
Logger.warning("Cannot add authorization to Cloud Api request")
return
header_dict = {
"Authorization": "Bearer {}".format(token)
}
self.add_headers(request, header_dict)

View File

@ -14,7 +14,7 @@ class ClusterPrinterMaterialStationSlot(ClusterPrintCoreConfiguration):
# \param material_remaining: How much material is remaining on the spool (between 0 and 1, or -1 for missing data). # \param material_remaining: How much material is remaining on the spool (between 0 and 1, or -1 for missing data).
# \param material_empty: Whether the material spool is too empty to be used. # \param material_empty: Whether the material spool is too empty to be used.
def __init__(self, slot_index: int, compatible: bool, material_remaining: float, def __init__(self, slot_index: int, compatible: bool, material_remaining: float,
material_empty: Optional[bool] = False, **kwargs): material_empty: Optional[bool] = False, **kwargs) -> None:
self.slot_index = slot_index self.slot_index = slot_index
self.compatible = compatible self.compatible = compatible
self.material_remaining = material_remaining self.material_remaining = material_remaining

View File

@ -0,0 +1,41 @@
{
"version": 2,
"name": "3DTech Semi-Professional",
"inherits": "fdmprinter",
"metadata": {
"visible": true,
"author": "3DTech",
"manufacturer": "3DTech",
"file_formats": "text/x-gcode",
"platform": "3dtech_semi_professional_platform.stl",
"platform_offset": [0, -2.5, 0 ],
"machine_extruder_trains":
{
"0": "3dtech_semi_professional_extruder_0"
}
},
"overrides": {
"machine_name": { "default_value": "3DTECH SP Control" },
"machine_width": {
"default_value": 250
},
"machine_depth": {
"default_value": 250
},
"machine_height": {
"default_value": 300
},
"machine_center_is_zero": {
"default_value": false
},
"machine_gcode_flavor": {
"default_value": "RepRap (Marlin/Sprinter)"
},
"machine_start_gcode": {
"default_value": "G28 ; home all axes\nG29 ;\nG1 Z5 F3000 ; lift\nG1 X5 Y25 F5000 ; move to prime\nG1 Z0.2 F3000 ; get ready to prime\nG92 E0 ; reset extrusion distance\nG1 Y100 E20 F600 ; prime nozzle\nG1 Y140 F5000 ; quick wipe"
},
"machine_end_gcode": {
"default_value": "M104 S0\nM140 S0 ; Retract the filament\nG92 E1\nG1 E-1 F300\nG28 X0 Y0\nM84"
}
}
}

View File

@ -5688,7 +5688,7 @@
"unit": "mm", "unit": "mm",
"enabled": "resolveOrValue('prime_tower_enable')", "enabled": "resolveOrValue('prime_tower_enable')",
"default_value": 200, "default_value": 200,
"value": "machine_width - max(extruderValue(adhesion_extruder_nr, 'brim_width') * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 if adhesion_type == 'brim' or (prime_tower_brim_enable and adhesion_type != 'raft') else (extruderValue(adhesion_extruder_nr, 'raft_margin') if adhesion_type == 'raft' else (extruderValue(adhesion_extruder_nr, 'skirt_gap') if adhesion_type == 'skirt' else 0)), max(extruderValues('travel_avoid_distance'))) - max(extruderValues('support_offset')) - sum(extruderValues('skirt_brim_line_width')) * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 - (resolveOrValue('draft_shield_dist') if resolveOrValue('draft_shield_enabled') else 0) - 1", "value": "machine_width - max(extruderValue(adhesion_extruder_nr, 'brim_width') * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 if adhesion_type == 'brim' or (prime_tower_brim_enable and adhesion_type != 'raft') else (extruderValue(adhesion_extruder_nr, 'raft_margin') if adhesion_type == 'raft' else (extruderValue(adhesion_extruder_nr, 'skirt_gap') if adhesion_type == 'skirt' else 0)), max(extruderValues('travel_avoid_distance'))) - max(extruderValues('support_offset')) - sum(extruderValues('skirt_brim_line_width')) * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 - (resolveOrValue('draft_shield_dist') if resolveOrValue('draft_shield_enabled') else 0) - max(map(abs, extruderValues('machine_nozzle_offset_x'))) - 1",
"maximum_value": "machine_width / 2 if machine_center_is_zero else machine_width", "maximum_value": "machine_width / 2 if machine_center_is_zero else machine_width",
"minimum_value": "resolveOrValue('prime_tower_size') - machine_width / 2 if machine_center_is_zero else resolveOrValue('prime_tower_size')", "minimum_value": "resolveOrValue('prime_tower_size') - machine_width / 2 if machine_center_is_zero else resolveOrValue('prime_tower_size')",
"settable_per_mesh": false, "settable_per_mesh": false,
@ -5702,7 +5702,7 @@
"unit": "mm", "unit": "mm",
"enabled": "resolveOrValue('prime_tower_enable')", "enabled": "resolveOrValue('prime_tower_enable')",
"default_value": 200, "default_value": 200,
"value": "machine_depth - prime_tower_size - max(extruderValue(adhesion_extruder_nr, 'brim_width') * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 if adhesion_type == 'brim' or (prime_tower_brim_enable and adhesion_type != 'raft') else (extruderValue(adhesion_extruder_nr, 'raft_margin') if adhesion_type == 'raft' else (extruderValue(adhesion_extruder_nr, 'skirt_gap') if adhesion_type == 'skirt' else 0)), max(extruderValues('travel_avoid_distance'))) - max(extruderValues('support_offset')) - sum(extruderValues('skirt_brim_line_width')) * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 - (resolveOrValue('draft_shield_dist') if resolveOrValue('draft_shield_enabled') else 0) - 1", "value": "machine_depth - prime_tower_size - max(extruderValue(adhesion_extruder_nr, 'brim_width') * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 if adhesion_type == 'brim' or (prime_tower_brim_enable and adhesion_type != 'raft') else (extruderValue(adhesion_extruder_nr, 'raft_margin') if adhesion_type == 'raft' else (extruderValue(adhesion_extruder_nr, 'skirt_gap') if adhesion_type == 'skirt' else 0)), max(extruderValues('travel_avoid_distance'))) - max(extruderValues('support_offset')) - sum(extruderValues('skirt_brim_line_width')) * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 - (resolveOrValue('draft_shield_dist') if resolveOrValue('draft_shield_enabled') else 0) - max(map(abs, extruderValues('machine_nozzle_offset_y'))) - 1",
"maximum_value": "machine_depth / 2 - resolveOrValue('prime_tower_size') if machine_center_is_zero else machine_depth - resolveOrValue('prime_tower_size')", "maximum_value": "machine_depth / 2 - resolveOrValue('prime_tower_size') if machine_center_is_zero else machine_depth - resolveOrValue('prime_tower_size')",
"minimum_value": "machine_depth / -2 if machine_center_is_zero else 0", "minimum_value": "machine_depth / -2 if machine_center_is_zero else 0",
"settable_per_mesh": false, "settable_per_mesh": false,

View File

@ -12,7 +12,7 @@
"exclude_materials": [ "exclude_materials": [
"chromatik_pla", "chromatik_pla",
"dsm_arnitel2045_175", "dsm_novamid1070_175", "dsm_arnitel2045_175", "dsm_novamid1070_175",
"emotiontech_abs", "emotiontech_petg", "emotiontech_pla", "emotiontech_pva-m", "emotiontech_pva-oks", "emotiontech_pva-s", "emotiontech_tpu98a", "emotiontech_abs", "emotiontech_asax", "emotiontech_hips", "emotiontech_petg", "emotiontech_pla", "emotiontech_pva-m", "emotiontech_pva-oks", "emotiontech_pva-s", "emotiontech_tpu98a",
"fabtotum_abs", "fabtotum_nylon", "fabtotum_pla", "fabtotum_tpu", "fabtotum_abs", "fabtotum_nylon", "fabtotum_pla", "fabtotum_tpu",
"fiberlogy_hd_pla", "fiberlogy_hd_pla",
"filo3d_pla", "filo3d_pla_green", "filo3d_pla_red", "filo3d_pla", "filo3d_pla_green", "filo3d_pla_red",
@ -67,7 +67,7 @@
"material_print_temp_wait": {"default_value": false }, "material_print_temp_wait": {"default_value": false },
"material_bed_temp_wait": {"default_value": false }, "material_bed_temp_wait": {"default_value": false },
"machine_max_feedrate_z": {"default_value": 10 }, "machine_max_feedrate_z": {"default_value": 10 },
"machine_acceleration": {"default_value": 500 }, "machine_acceleration": {"default_value": 180 },
"machine_start_gcode": {"default_value": "\n;Neither Hybrid AM Systems nor any of Hybrid AM Systems representatives has any liabilities or gives any warranties on this .gcode file, or on any or all objects made with this .gcode file.\n\nM140 S{material_bed_temperature_layer_0}\n\nM117 Homing Y ......\nG28 Y\nM117 Homing X ......\nG28 X\nM117 Homing Z ......\nG28 Z F100\n\nG1 Z10 F900\nG1 X-30 Y100 F12000\n\nM190 S{material_bed_temperature_layer_0}\nM117 HMS434 Printing ...\n\n" }, "machine_start_gcode": {"default_value": "\n;Neither Hybrid AM Systems nor any of Hybrid AM Systems representatives has any liabilities or gives any warranties on this .gcode file, or on any or all objects made with this .gcode file.\n\nM140 S{material_bed_temperature_layer_0}\n\nM117 Homing Y ......\nG28 Y\nM117 Homing X ......\nG28 X\nM117 Homing Z ......\nG28 Z F100\n\nG1 Z10 F900\nG1 X-30 Y100 F12000\n\nM190 S{material_bed_temperature_layer_0}\nM117 HMS434 Printing ...\n\n" },
"machine_end_gcode": {"default_value": "" }, "machine_end_gcode": {"default_value": "" },

View File

@ -0,0 +1,96 @@
{
"version": 2,
"name": "MAKEiT Pro-MX",
"inherits": "fdmprinter",
"metadata": {
"visible": true,
"author": "unknown",
"manufacturer": "MAKEiT 3D",
"file_formats": "text/x-gcode",
"has_materials": false,
"machine_extruder_trains":
{
"0": "makeit_mx_dual_1st",
"1": "makeit_mx_dual_2nd"
}
},
"overrides": {
"machine_name": { "default_value": "MAKEiT Pro-MX" },
"machine_width": {
"default_value": 200
},
"machine_height": {
"default_value": 330
},
"machine_depth": {
"default_value": 240
},
"machine_center_is_zero": {
"default_value": false
},
"machine_head_with_fans_polygon":
{
"default_value": [
[ -200, 240 ],
[ -200, -32 ],
[ 200, 240 ],
[ 200, -32 ]
]
},
"gantry_height": {
"value": "200"
},
"machine_use_extruder_offset_to_offset_coords": {
"default_value": true
},
"machine_gcode_flavor": {
"default_value": "RepRap (Marlin/Sprinter)"
},
"machine_start_gcode": {
"default_value": "G21 ;metric values\nG90 ;absolute positioning\nM82 ;set extruder to absolute mode\nG92 E0 ;zero the extruded length\nG28 ;home\nG1 F200 E30 ;extrude 30 mm of feed stock\nG92 E0 ;zero the extruded length\nG1 E-5 ;retract 5 mm\nG28 SC ;Do homeing, clean nozzles and let printer to know that printing started\nG92 X-6 ;Sets Curas checker board to match printers heated bed coordinates\nG1 F{speed_travel}\nM117 Printing..."
},
"machine_end_gcode": {
"default_value": "M104 T0 S0 ;1st extruder heater off\nM104 T1 S0 ;2nd extruder heater off\nM140 S0 ;heated bed heater off (if you have it)\nG91 ;relative positioning\nG1 E-5 F9000 ;retract the filament a bit before lifting the nozzle, to release some of the pressure\nG1 Z+5 X+20 Y+20 F9000 ;move Z up a bit\nM117 MAKEiT Pro@Done\nG28 X0 Y0 ;move X/Y to min endstops, so the head is out of the way\nM84 ;steppers off\nG90 ;absolute positioning\nM81"
},
"machine_extruder_count": {
"default_value": 2
},
"print_sequence": {
"enabled": false
},
"prime_tower_position_x": {
"value": "185"
},
"prime_tower_position_y": {
"value": "160"
},
"layer_height": {
"default_value": 0.2
},
"retraction_speed": {
"default_value": 180
},
"infill_sparse_density": {
"default_value": 20
},
"retraction_amount": {
"default_value": 6
},
"speed_print": {
"default_value": 60
},
"wall_thickness": {
"default_value": 1.2
},
"cool_min_layer_time_fan_speed_max": {
"default_value": 5
},
"adhesion_type": {
"default_value": "skirt"
},
"machine_heated_bed": {
"default_value": true
}
}
}

View File

@ -0,0 +1,15 @@
{
"version": 2,
"name": "Extruder 1",
"inherits": "fdmextruder",
"metadata": {
"machine": "3dtech_semi_professional",
"position": "0"
},
"overrides": {
"extruder_nr": { "default_value": 0 },
"machine_nozzle_size": { "default_value": 0.4 },
"material_diameter": { "default_value": 1.75 }
}
}

View File

@ -0,0 +1,27 @@
{
"version": 2,
"name": "1st Extruder",
"inherits": "fdmextruder",
"metadata": {
"machine": "makeit_pro_mx",
"position": "0"
},
"overrides": {
"extruder_nr": {
"default_value": 0,
"maximum_value": "1"
},
"machine_nozzle_offset_x": { "default_value": 0.0 },
"machine_nozzle_offset_y": { "default_value": 0.0 },
"machine_nozzle_size": { "default_value": 0.4 },
"material_diameter": { "default_value": 1.75 },
"machine_extruder_start_pos_abs": { "default_value": true },
"machine_extruder_start_pos_x": { "value": "prime_tower_position_x" },
"machine_extruder_start_pos_y": { "value": "prime_tower_position_y" },
"machine_extruder_end_pos_abs": { "default_value": true },
"machine_extruder_end_pos_x": { "value": "prime_tower_position_x" },
"machine_extruder_end_pos_y": { "value": "prime_tower_position_y" }
}
}

View File

@ -0,0 +1,27 @@
{
"version": 2,
"name": "2nd Extruder",
"inherits": "fdmextruder",
"metadata": {
"machine": "makeit_pro_mx",
"position": "1"
},
"overrides": {
"extruder_nr": {
"default_value": 1,
"maximum_value": "1"
},
"machine_nozzle_offset_x": { "default_value": 0.0 },
"machine_nozzle_offset_y": { "default_value": 0.0 },
"machine_nozzle_size": { "default_value": 0.4 },
"material_diameter": { "default_value": 1.75 },
"machine_extruder_start_pos_abs": { "default_value": true },
"machine_extruder_start_pos_x": { "value": "prime_tower_position_x" },
"machine_extruder_start_pos_y": { "value": "prime_tower_position_y" },
"machine_extruder_end_pos_abs": { "default_value": true },
"machine_extruder_end_pos_x": { "value": "prime_tower_position_x" },
"machine_extruder_end_pos_y": { "value": "prime_tower_position_y" }
}
}

Binary file not shown.

View File

@ -54,6 +54,7 @@ Item
property alias manageProfiles: manageProfilesAction; property alias manageProfiles: manageProfilesAction;
property alias manageMaterials: manageMaterialsAction; property alias manageMaterials: manageMaterialsAction;
property alias marketplaceMaterials: marketplaceMaterialsAction;
property alias preferences: preferencesAction; property alias preferences: preferencesAction;
@ -188,6 +189,12 @@ Item
shortcut: "Ctrl+K" shortcut: "Ctrl+K"
} }
Action
{
id: marketplaceMaterialsAction
text: catalog.i18nc("@action:inmenu", "Add more materials from Marketplace")
}
Action Action
{ {
id: updateProfileAction; id: updateProfileAction;

View File

@ -1,4 +1,4 @@
// Copyright (c) 2019 Ultimaker B.V. // Copyright (c) 2020 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher. // Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7 import QtQuick 2.7
@ -21,7 +21,16 @@ UM.MainWindow
id: base id: base
// Cura application window title // Cura application window title
title: PrintInformation.jobName + " - " + catalog.i18nc("@title:window", CuraApplication.applicationDisplayName) title:
{
let result = "";
if(PrintInformation.jobName != "")
{
result += PrintInformation.jobName + " - ";
}
result += CuraApplication.applicationDisplayName;
return result;
}
backgroundColor: UM.Theme.getColor("viewport_background") backgroundColor: UM.Theme.getColor("viewport_background")
@ -244,23 +253,6 @@ UM.MainWindow
} }
} }
Toolbar
{
// The toolbar is the left bar that is populated by all the tools (which are dynamicly populated by
// plugins)
id: toolbar
property int mouseX: base.mouseX
property int mouseY: base.mouseY
anchors
{
verticalCenter: parent.verticalCenter
left: parent.left
}
visible: CuraApplication.platformActivity && !PrintInformation.preSliced
}
ObjectSelector ObjectSelector
{ {
id: objectSelector id: objectSelector
@ -302,6 +294,23 @@ UM.MainWindow
} }
} }
Toolbar
{
// The toolbar is the left bar that is populated by all the tools (which are dynamicly populated by
// plugins)
id: toolbar
property int mouseX: base.mouseX
property int mouseY: base.mouseY
anchors
{
verticalCenter: parent.verticalCenter
left: parent.left
}
visible: CuraApplication.platformActivity && !PrintInformation.preSliced
}
// A hint for the loaded content view. Overlay items / controls can safely be placed in this area // A hint for the loaded content view. Overlay items / controls can safely be placed in this area
Item { Item {
id: mainSafeArea id: mainSafeArea

View File

@ -154,7 +154,7 @@ Item
} }
} }
// show the plugin browser dialog // show the Toolbox
Connections Connections
{ {
target: Cura.Actions.browsePackages target: Cura.Actions.browsePackages
@ -163,4 +163,15 @@ Item
curaExtensions.callExtensionMethod("Toolbox", "launch") curaExtensions.callExtensionMethod("Toolbox", "launch")
} }
} }
// Show the Marketplace dialog at the materials tab
Connections
{
target: Cura.Actions.marketplaceMaterials
onTriggered:
{
curaExtensions.callExtensionMethod("Toolbox", "launch")
curaExtensions.callExtensionMethod("Toolbox", "setViewCategoryToMaterials")
}
}
} }

View File

@ -157,4 +157,11 @@ Menu
{ {
action: Cura.Actions.manageMaterials action: Cura.Actions.manageMaterials
} }
MenuSeparator {}
MenuItem
{
action: Cura.Actions.marketplaceMaterials
}
} }

View File

@ -520,6 +520,7 @@
"action_button": [15.0, 2.5], "action_button": [15.0, 2.5],
"action_button_icon": [1.0, 1.0], "action_button_icon": [1.0, 1.0],
"action_button_radius": [0.15, 0.15], "action_button_radius": [0.15, 0.15],
"dialog_primary_button_padding": [3.0, 0],
"radio_button": [1.3, 1.3], "radio_button": [1.3, 1.3],

View File

@ -0,0 +1,65 @@
import os
import re
import sys
from pathlib import Path
"""
Run this file with the Cura project root as the working directory
Checks for invalid imports. When importing from plugins, there will be no problems when running from source,
but for some build types the plugins dir is not on the path, so relative imports should be used instead. eg:
from ..UltimakerCloudScope import UltimakerCloudScope <-- OK
import plugins.Toolbox.src ... <-- NOT OK
"""
class InvalidImportsChecker:
# compile regex
REGEX = re.compile(r"^\s*(from plugins|import plugins)")
def check(self):
""" Checks for invalid imports
:return: True if checks passed, False when the test fails
"""
cwd = os.getcwd()
cura_result = checker.check_dir(os.path.join(cwd, "cura"))
plugins_result = checker.check_dir(os.path.join(cwd, "plugins"))
result = cura_result and plugins_result
if not result:
print("error: sources contain invalid imports. Use relative imports when referencing plugin source files")
return result
def check_dir(self, root_dir: str) -> bool:
""" Checks a directory for invalid imports
:return: True if checks passed, False when the test fails
"""
passed = True
for path_like in Path(root_dir).rglob('*.py'):
if not self.check_file(str(path_like)):
passed = False
return passed
def check_file(self, file_path):
""" Checks a file for invalid imports
:return: True if checks passed, False when the test fails
"""
passed = True
with open(file_path, 'r', encoding = "utf-8") as inputFile:
# loop through each line in file
for line_i, line in enumerate(inputFile, 1):
# check if we have a regex match
match = self.REGEX.search(line)
if match:
path = os.path.relpath(file_path)
print("{path}:{line_i}:{match}".format(path=path, line_i=line_i, match=match.group(1)))
passed = False
return passed
if __name__ == "__main__":
checker = InvalidImportsChecker()
sys.exit(0 if checker.check() else 1)

5
test-in-docker.sh Executable file
View File

@ -0,0 +1,5 @@
sudo rm -rf ./build ./Uranium
sudo docker run -it --rm \
-v "$(pwd):/srv/cura" ultimaker/cura-build-environment \
/srv/cura/docker/build.sh
sudo rm -rf ./build ./Uranium