mirror of
https://git.mirrors.martin98.com/https://github.com/Ultimaker/Cura
synced 2025-05-01 08:14:22 +08:00
Merge branch 'master' into CURA-6627_Store_extra_data_project_files
This commit is contained in:
commit
a3825c1f14
1
.github/workflows/cicd.yml
vendored
1
.github/workflows/cicd.yml
vendored
@ -6,6 +6,7 @@ on:
|
||||
- master
|
||||
- 'WIP**'
|
||||
- '4.*'
|
||||
- 'CURA-*'
|
||||
pull_request:
|
||||
jobs:
|
||||
build:
|
||||
|
@ -56,6 +56,13 @@ function(cura_add_test)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
#Add test for import statements which are not compatible with all builds
|
||||
add_test(
|
||||
NAME "invalid-imports"
|
||||
COMMAND ${Python3_EXECUTABLE} scripts/check_invalid_imports.py
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
)
|
||||
|
||||
cura_add_test(NAME pytest-main DIRECTORY ${CMAKE_SOURCE_DIR}/tests PYTHONPATH "${CMAKE_SOURCE_DIR}|${URANIUM_DIR}")
|
||||
|
||||
file(GLOB_RECURSE _plugins plugins/*/__init__.py)
|
||||
|
@ -69,7 +69,7 @@ class Arrange:
|
||||
points = copy.deepcopy(vertices._points)
|
||||
|
||||
# After scaling (like up to 0.1 mm) the node might not have points
|
||||
if not points:
|
||||
if not points.size:
|
||||
continue
|
||||
|
||||
shape_arr = ShapeArray.fromPolygon(points, scale = scale)
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import os
|
||||
@ -1827,15 +1827,21 @@ class CuraApplication(QtApplication):
|
||||
|
||||
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.
|
||||
if not Selection.hasSelection():
|
||||
node = self.getController().getScene().findObject(cast(SelectionPass, self.getRenderer().getRenderPass("selection")).getIdAtPosition(x, y))
|
||||
if node:
|
||||
parent = node.getParent()
|
||||
while(parent and parent.callDecoration("isGroup")):
|
||||
node = parent
|
||||
parent = node.getParent()
|
||||
if Selection.hasSelection():
|
||||
return
|
||||
selection_pass = cast(SelectionPass, self.getRenderer().getRenderPass("selection"))
|
||||
if not selection_pass: # If you right-click before the rendering has been initialised there might not be a selection pass yet.
|
||||
print("--------------ding! Got the crash.")
|
||||
return
|
||||
node = self.getController().getScene().findObject(selection_pass.getIdAtPosition(x, y))
|
||||
if not node:
|
||||
return
|
||||
parent = node.getParent()
|
||||
while parent and parent.callDecoration("isGroup"):
|
||||
node = parent
|
||||
parent = node.getParent()
|
||||
|
||||
Selection.add(node)
|
||||
Selection.add(node)
|
||||
|
||||
@pyqtSlot()
|
||||
def showMoreInformationDialogForAnonymousDataCollection(self):
|
||||
|
@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def initialize(self) -> None:
|
||||
|
@ -34,7 +34,7 @@ class MaterialBrandsModel(BaseMaterialsModel):
|
||||
brand_item_list = []
|
||||
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():
|
||||
# Do not include the materials from a to-be-removed package
|
||||
if bool(container_node.getMetaDataEntry("removed", False)):
|
||||
|
@ -51,7 +51,7 @@ class VariantNode(ContainerNode):
|
||||
# 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.
|
||||
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.
|
||||
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.
|
||||
|
@ -122,6 +122,6 @@ class _ObjectOrder:
|
||||
# \param order List of indices in which to print objects, ordered by printing
|
||||
# order.
|
||||
# \param todo: List of indices which are not yet inserted into the order list.
|
||||
def __init__(self, order: List[SceneNode], todo: List[SceneNode]):
|
||||
def __init__(self, order: List[SceneNode], todo: List[SceneNode]) -> None:
|
||||
self.order = order
|
||||
self.todo = todo
|
||||
|
@ -8,7 +8,7 @@ from UM.Scene.SceneNode import SceneNode
|
||||
|
||||
## A specialised operation designed specifically to modify the previous operation.
|
||||
class PlatformPhysicsOperation(Operation):
|
||||
def __init__(self, node: SceneNode, translation: Vector):
|
||||
def __init__(self, node: SceneNode, translation: Vector) -> None:
|
||||
super().__init__()
|
||||
self._node = node
|
||||
self._old_transformation = node.getLocalTransformation()
|
||||
|
@ -14,7 +14,7 @@ class SetParentOperation(Operation.Operation):
|
||||
#
|
||||
# \param node The node which will be reparented.
|
||||
# \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__()
|
||||
self._node = node
|
||||
self._parent = parent_node
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from 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)
|
||||
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.
|
||||
#
|
||||
# \param machine_id The machine to remove the extruders for.
|
||||
|
@ -13,28 +13,46 @@ export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}"
|
||||
|
||||
cd "${PROJECT_DIR}"
|
||||
|
||||
|
||||
|
||||
#
|
||||
# Clone Uranium and set PYTHONPATH first
|
||||
#
|
||||
|
||||
# Check the branch to use:
|
||||
# 1. Use the Uranium branch with the branch same if it exists.
|
||||
# 2. Otherwise, use the default branch name "master"
|
||||
# Check the branch to use for Uranium.
|
||||
# It tries the following branch names and uses the first one that's available.
|
||||
# - 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_HEAD_REF: ${GITHUB_HEAD_REF}"
|
||||
echo "GITHUB_BASE_REF: ${GITHUB_BASE_REF}"
|
||||
|
||||
GIT_REF_NAME="${GITHUB_REF}"
|
||||
if [ -n "${GITHUB_BASE_REF}" ]; then
|
||||
GIT_REF_NAME="${GITHUB_BASE_REF}"
|
||||
fi
|
||||
GIT_REF_NAME="$(basename "${GIT_REF_NAME}")"
|
||||
|
||||
URANIUM_BRANCH="${GIT_REF_NAME:-master}"
|
||||
output="$(git ls-remote --heads https://github.com/Ultimaker/Uranium.git "${URANIUM_BRANCH}")"
|
||||
if [ -z "${output}" ]; then
|
||||
echo "Could not find Uranium banch ${URANIUM_BRANCH}, fallback to use master."
|
||||
URANIUM_BRANCH="master"
|
||||
fi
|
||||
GIT_REF_NAME_LIST=( "${GITHUB_HEAD_REF}" "${GITHUB_BASE_REF}" "${GITHUB_REF}" "master" )
|
||||
for git_ref_name in "${GIT_REF_NAME_LIST[@]}"
|
||||
do
|
||||
if [ -z "${git_ref_name}" ]; then
|
||||
continue
|
||||
fi
|
||||
git_ref_name="$(basename "${git_ref_name}")"
|
||||
# Skip refs/pull/1234/merge as pull requests use it as GITHUB_REF
|
||||
if [[ "${git_ref_name}" == "merge" ]]; then
|
||||
echo "Skip [${git_ref_name}]"
|
||||
continue
|
||||
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} ..."
|
||||
git clone --depth=1 -b "${URANIUM_BRANCH}" https://github.com/Ultimaker/Uranium.git "${PROJECT_DIR}"/Uranium
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import numpy
|
||||
@ -171,146 +171,145 @@ class StartSliceJob(Job):
|
||||
self.setResult(StartJobResult.ObjectSettingError)
|
||||
return
|
||||
|
||||
with self._scene.getSceneLock():
|
||||
# Remove old layer data.
|
||||
for node in DepthFirstIterator(self._scene.getRoot()):
|
||||
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.
|
||||
cast(SceneNode, node.getParent()).removeChild(node)
|
||||
break
|
||||
# Remove old layer data.
|
||||
for node in DepthFirstIterator(self._scene.getRoot()):
|
||||
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.
|
||||
cast(SceneNode, node.getParent()).removeChild(node)
|
||||
break
|
||||
|
||||
# Get the objects in their groups to print.
|
||||
object_groups = []
|
||||
if stack.getProperty("print_sequence", "value") == "one_at_a_time":
|
||||
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:
|
||||
# Get the objects in their groups to print.
|
||||
object_groups = []
|
||||
if stack.getProperty("print_sequence", "value") == "one_at_a_time":
|
||||
for node in OneAtATimeIterator(self._scene.getRoot()):
|
||||
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
|
||||
if node.callDecoration("getBuildPlateNumber") != self._build_plate_number:
|
||||
continue
|
||||
if getattr(node, "_outside_buildarea", False) and not is_non_printing_mesh:
|
||||
continue
|
||||
# Node can't be printed, so don't bother sending it.
|
||||
if getattr(node, "_outside_buildarea", False):
|
||||
continue
|
||||
|
||||
temp_list.append(node)
|
||||
if not is_non_printing_mesh:
|
||||
has_printing_mesh = True
|
||||
# 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
|
||||
|
||||
Job.yieldThread()
|
||||
|
||||
# If the list doesn't have any model with suitable settings then clean the list
|
||||
# otherwise CuraEngine will crash
|
||||
if not has_printing_mesh:
|
||||
temp_list.clear()
|
||||
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 = []
|
||||
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()
|
||||
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)
|
||||
|
||||
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:
|
||||
# Find a reason not to add the node
|
||||
if node.callDecoration("getBuildPlateNumber") != self._build_plate_number:
|
||||
continue
|
||||
if getattr(node, "_outside_buildarea", False) and not is_non_printing_mesh:
|
||||
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
|
||||
temp_list.append(node)
|
||||
if not is_non_printing_mesh:
|
||||
has_printing_mesh = True
|
||||
|
||||
# Convert from Y up axes to Z up axes. Equals a 90 degree rotation.
|
||||
verts[:, [1, 2]] = verts[:, [2, 1]]
|
||||
verts[:, 1] *= -1
|
||||
Job.yieldThread()
|
||||
|
||||
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)
|
||||
# If the list doesn't have any model with suitable settings then clean the list
|
||||
# otherwise CuraEngine will crash
|
||||
if not has_printing_mesh:
|
||||
temp_list.clear()
|
||||
|
||||
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)
|
||||
|
||||
@ -344,10 +343,7 @@ class StartSliceJob(Job):
|
||||
result["time"] = time.strftime("%H:%M:%S") #Some extra settings.
|
||||
result["date"] = time.strftime("%d-%m-%Y")
|
||||
result["day"] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][int(time.strftime("%w"))]
|
||||
|
||||
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
|
||||
result["initial_extruder_nr"] = CuraApplication.getInstance().getExtruderManager().getInitialExtruderNr()
|
||||
|
||||
return result
|
||||
|
||||
|
@ -35,7 +35,7 @@ class GCodeStep():
|
||||
Class to store the current value of each G_Code parameter
|
||||
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_x = 0
|
||||
self.step_y = 0
|
||||
|
@ -11,7 +11,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class SimulationViewProxy(QObject):
|
||||
def __init__(self, simulation_view: "SimulationView", parent=None):
|
||||
def __init__(self, simulation_view: "SimulationView", parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._simulation_view = simulation_view
|
||||
self._current_layer = 0
|
||||
|
@ -2,6 +2,7 @@
|
||||
# Toolbox is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from .src import Toolbox
|
||||
from .src.CloudSync.SyncOrchestrator import SyncOrchestrator
|
||||
|
||||
|
||||
def getMetaData():
|
||||
@ -9,4 +10,6 @@ def getMetaData():
|
||||
|
||||
|
||||
def register(app):
|
||||
return {"extension": Toolbox.Toolbox(app)}
|
||||
return {
|
||||
"extension": [Toolbox.Toolbox(app), SyncOrchestrator(app)]
|
||||
}
|
||||
|
@ -96,17 +96,12 @@ Window
|
||||
visible: toolbox.restartRequired
|
||||
height: visible ? UM.Theme.getSize("toolbox_footer").height : 0
|
||||
}
|
||||
// TODO: Clean this up:
|
||||
|
||||
Connections
|
||||
{
|
||||
target: toolbox
|
||||
onShowLicenseDialog:
|
||||
{
|
||||
licenseDialog.pluginName = toolbox.getLicenseDialogPluginName();
|
||||
licenseDialog.licenseContent = toolbox.getLicenseDialogLicenseContent();
|
||||
licenseDialog.pluginFileLocation = toolbox.getLicenseDialogPluginFileLocation();
|
||||
licenseDialog.show();
|
||||
}
|
||||
onShowLicenseDialog: { licenseDialog.show() }
|
||||
onCloseLicenseDialog: { licenseDialog.close() }
|
||||
}
|
||||
|
||||
ToolboxLicenseDialog
|
||||
|
@ -48,13 +48,13 @@ UM.Dialog{
|
||||
{
|
||||
font: UM.Theme.getFont("default")
|
||||
text: catalog.i18nc("@label", "The following packages will be added:")
|
||||
visible: toolbox.has_compatible_packages
|
||||
visible: subscribedPackagesModel.hasCompatiblePackages
|
||||
color: UM.Theme.getColor("text")
|
||||
height: contentHeight + UM.Theme.getSize("default_margin").height
|
||||
}
|
||||
Repeater
|
||||
{
|
||||
model: toolbox.subscribedPackagesModel
|
||||
model: subscribedPackagesModel
|
||||
Component
|
||||
{
|
||||
Item
|
||||
@ -74,7 +74,7 @@ UM.Dialog{
|
||||
}
|
||||
Label
|
||||
{
|
||||
text: model.name
|
||||
text: model.display_name
|
||||
font: UM.Theme.getFont("medium_bold")
|
||||
anchors.left: packageIcon.right
|
||||
anchors.leftMargin: UM.Theme.getSize("default_margin").width
|
||||
@ -91,20 +91,20 @@ UM.Dialog{
|
||||
{
|
||||
font: UM.Theme.getFont("default")
|
||||
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")
|
||||
height: contentHeight + UM.Theme.getSize("default_margin").height
|
||||
}
|
||||
Repeater
|
||||
{
|
||||
model: toolbox.subscribedPackagesModel
|
||||
model: subscribedPackagesModel
|
||||
Component
|
||||
{
|
||||
Item
|
||||
{
|
||||
width: parent.width
|
||||
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
|
||||
Image
|
||||
{
|
||||
@ -117,7 +117,7 @@ UM.Dialog{
|
||||
}
|
||||
Label
|
||||
{
|
||||
text: model.name
|
||||
text: model.display_name
|
||||
font: UM.Theme.getFont("medium_bold")
|
||||
anchors.left: packageIcon.right
|
||||
anchors.leftMargin: UM.Theme.getSize("default_margin").width
|
||||
@ -125,6 +125,26 @@ UM.Dialog{
|
||||
color: UM.Theme.getColor("text")
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
UM.TooltipArea
|
||||
{
|
||||
width: childrenRect.width;
|
||||
height: childrenRect.height;
|
||||
text: catalog.i18nc("@info:tooltip", "Dismisses the package and won't be shown in this dialog anymore")
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: packageIcon.verticalCenter
|
||||
Label
|
||||
{
|
||||
text: "(Dismiss)"
|
||||
font: UM.Theme.getFont("small")
|
||||
color: UM.Theme.getColor("text")
|
||||
MouseArea
|
||||
{
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
anchors.fill: parent
|
||||
onClicked: handler.dismissIncompatiblePackage(subscribedPackagesModel, model.package_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -132,13 +152,16 @@ UM.Dialog{
|
||||
|
||||
} // End of ScrollView
|
||||
|
||||
Cura.ActionButton
|
||||
Cura.PrimaryButton
|
||||
{
|
||||
id: nextButton
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.margins: UM.Theme.getSize("default_margin").height
|
||||
text: catalog.i18nc("@button", "Next")
|
||||
onClicked: accept()
|
||||
leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width
|
||||
rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -10,65 +10,65 @@ import QtQuick.Controls.Styles 1.4
|
||||
// TODO: Switch to QtQuick.Controls 2.x and remove QtQuick.Controls.Styles
|
||||
|
||||
import UM 1.1 as UM
|
||||
import Cura 1.6 as Cura
|
||||
|
||||
UM.Dialog
|
||||
{
|
||||
title: catalog.i18nc("@title:window", "Plugin License Agreement")
|
||||
id: licenseDialog
|
||||
title: licenseModel.dialogTitle
|
||||
minimumWidth: UM.Theme.getSize("license_window_minimum").width
|
||||
minimumHeight: UM.Theme.getSize("license_window_minimum").height
|
||||
width: minimumWidth
|
||||
height: minimumHeight
|
||||
property var pluginName;
|
||||
property var licenseContent;
|
||||
property var pluginFileLocation;
|
||||
|
||||
Item
|
||||
{
|
||||
anchors.fill: parent
|
||||
|
||||
UM.I18nCatalog{id: catalog; name: "cura"}
|
||||
|
||||
|
||||
Label
|
||||
{
|
||||
id: licenseTitle
|
||||
id: licenseHeader
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
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
|
||||
renderType: Text.NativeRendering
|
||||
}
|
||||
TextArea
|
||||
{
|
||||
id: licenseText
|
||||
anchors.top: licenseTitle.bottom
|
||||
anchors.top: licenseHeader.bottom
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: UM.Theme.getSize("default_margin").height
|
||||
readOnly: true
|
||||
text: licenseDialog.licenseContent || ""
|
||||
text: licenseModel.licenseText
|
||||
}
|
||||
}
|
||||
rightButtons:
|
||||
[
|
||||
Button
|
||||
Cura.PrimaryButton
|
||||
{
|
||||
id: acceptButton
|
||||
anchors.margins: UM.Theme.getSize("default_margin").width
|
||||
text: catalog.i18nc("@action:button", "Accept")
|
||||
onClicked:
|
||||
{
|
||||
licenseDialog.close();
|
||||
toolbox.install(licenseDialog.pluginFileLocation);
|
||||
toolbox.subscribe(licenseDialog.pluginName);
|
||||
}
|
||||
},
|
||||
Button
|
||||
leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width
|
||||
rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width
|
||||
|
||||
text: catalog.i18nc("@button", "Agree")
|
||||
onClicked: { handler.onLicenseAccepted() }
|
||||
}
|
||||
]
|
||||
|
||||
leftButtons:
|
||||
[
|
||||
Cura.SecondaryButton
|
||||
{
|
||||
id: declineButton
|
||||
anchors.margins: UM.Theme.getSize("default_margin").width
|
||||
text: catalog.i18nc("@action:button", "Decline")
|
||||
onClicked:
|
||||
{
|
||||
licenseDialog.close();
|
||||
}
|
||||
text: catalog.i18nc("@button", "Decline and remove from account")
|
||||
onClicked: { handler.onLicenseDeclined() }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -4,7 +4,7 @@
|
||||
import re
|
||||
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
|
||||
|
||||
|
28
plugins/Toolbox/src/CloudApiModel.py
Normal file
28
plugins/Toolbox/src/CloudApiModel.py
Normal 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
|
||||
)
|
110
plugins/Toolbox/src/CloudSync/CloudPackageChecker.py
Normal file
110
plugins/Toolbox/src/CloudSync/CloudPackageChecker.py
Normal 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")
|
23
plugins/Toolbox/src/CloudSync/CloudPackageManager.py
Normal file
23
plugins/Toolbox/src/CloudSync/CloudPackageManager.py
Normal 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)
|
||||
|
40
plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py
Normal file
40
plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py
Normal 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)
|
136
plugins/Toolbox/src/CloudSync/DownloadPresenter.py
Normal file
136
plugins/Toolbox/src/CloudSync/DownloadPresenter.py
Normal 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
|
55
plugins/Toolbox/src/CloudSync/LicenseModel.py
Normal file
55
plugins/Toolbox/src/CloudSync/LicenseModel.py
Normal 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()
|
97
plugins/Toolbox/src/CloudSync/LicensePresenter.py
Normal file
97
plugins/Toolbox/src/CloudSync/LicensePresenter.py
Normal 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)
|
||||
|
||||
|
||||
|
31
plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py
Normal file
31
plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py
Normal 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()
|
82
plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py
Normal file
82
plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py
Normal 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)
|
||||
|
||||
|
102
plugins/Toolbox/src/CloudSync/SyncOrchestrator.py
Normal file
102
plugins/Toolbox/src/CloudSync/SyncOrchestrator.py
Normal 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()
|
0
plugins/Toolbox/src/CloudSync/__init__.py
Normal file
0
plugins/Toolbox/src/CloudSync/__init__.py
Normal file
@ -1,7 +1,8 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# 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
|
||||
|
||||
|
||||
|
@ -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
|
@ -4,7 +4,6 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import platform
|
||||
from typing import cast, Any, Dict, List, Set, TYPE_CHECKING, Tuple, Optional, Union
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot
|
||||
@ -15,16 +14,17 @@ from UM.PluginRegistry import PluginRegistry
|
||||
from UM.Extension import Extension
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Version import Version
|
||||
from UM.Message import Message
|
||||
|
||||
from cura import ApplicationMetadata
|
||||
from cura import UltimakerCloudAuthentication
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.Machines.ContainerTree import ContainerTree
|
||||
|
||||
from .CloudApiModel import CloudApiModel
|
||||
from .AuthorsModel import AuthorsModel
|
||||
from .CloudSync.CloudPackageManager import CloudPackageManager
|
||||
from .CloudSync.LicenseModel import LicenseModel
|
||||
from .PackagesModel import PackagesModel
|
||||
from .SubscribedPackagesModel import SubscribedPackagesModel
|
||||
from .UltimakerCloudScope import UltimakerCloudScope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from UM.TaskManagement.HttpRequestData import HttpRequestData
|
||||
@ -32,8 +32,9 @@ if TYPE_CHECKING:
|
||||
|
||||
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):
|
||||
def __init__(self, application: CuraApplication) -> None:
|
||||
super().__init__()
|
||||
@ -41,16 +42,13 @@ class Toolbox(QObject, Extension):
|
||||
self._application = application # type: CuraApplication
|
||||
|
||||
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:
|
||||
self._cloud_package_manager = CloudPackageManager(application) # type: CloudPackageManager
|
||||
self._download_request_data = None # type: Optional[HttpRequestData]
|
||||
self._download_progress = 0 # type: float
|
||||
self._is_downloading = False # type: bool
|
||||
self._request_headers = dict() # type: Dict[str, str]
|
||||
self._updateRequestHeader()
|
||||
self._scope = UltimakerCloudScope(application) # type: UltimakerCloudScope
|
||||
|
||||
self._request_urls = {} # type: Dict[str, str]
|
||||
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 = {
|
||||
"authors": [],
|
||||
"packages": [],
|
||||
"updates": [],
|
||||
"subscribed_packages": [],
|
||||
"updates": []
|
||||
} # type: Dict[str, List[Any]]
|
||||
|
||||
# Models:
|
||||
self._models = {
|
||||
"authors": AuthorsModel(self),
|
||||
"packages": PackagesModel(self),
|
||||
"updates": PackagesModel(self),
|
||||
"subscribed_packages": SubscribedPackagesModel(self),
|
||||
} # type: Dict[str, Union[AuthorsModel, PackagesModel, SubscribedPackagesModel]]
|
||||
"updates": PackagesModel(self)
|
||||
} # type: Dict[str, Union[AuthorsModel, PackagesModel]]
|
||||
|
||||
self._plugins_showcase_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_generic_model = PackagesModel(self)
|
||||
|
||||
self._license_model = LicenseModel()
|
||||
|
||||
# These properties are for keeping track of the UI state:
|
||||
# ----------------------------------------------------------------------
|
||||
# View category defines which filter to use, and therefore effectively
|
||||
@ -104,13 +102,9 @@ class Toolbox(QObject, Extension):
|
||||
self._restart_required = False # type: bool
|
||||
|
||||
# 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._restart_dialog_message = "" # type: str
|
||||
|
||||
self._application.initializationFinished.connect(self._onAppInitialized)
|
||||
self._application.getCuraAPI().account.accessTokenChanged.connect(self._updateRequestHeader)
|
||||
|
||||
# Signals:
|
||||
# --------------------------------------------------------------------------
|
||||
@ -128,11 +122,11 @@ class Toolbox(QObject, Extension):
|
||||
filterChanged = pyqtSignal()
|
||||
metadataChanged = pyqtSignal()
|
||||
showLicenseDialog = pyqtSignal()
|
||||
closeLicenseDialog = pyqtSignal()
|
||||
uninstallVariablesChanged = pyqtSignal()
|
||||
|
||||
## Go back to the start state (welcome screen or loading if no login required)
|
||||
def _restart(self):
|
||||
self._updateRequestHeader()
|
||||
# For an Essentials build, login is mandatory
|
||||
if not self._application.getCuraAPI().account.isLoggedIn and ApplicationMetadata.IsEnterpriseVersion:
|
||||
self.setViewPage("welcome")
|
||||
@ -140,17 +134,6 @@ class Toolbox(QObject, Extension):
|
||||
self.setViewPage("loading")
|
||||
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:
|
||||
self._package_id_to_uninstall = None # type: Optional[str]
|
||||
self._package_name_to_uninstall = ""
|
||||
@ -159,35 +142,25 @@ class Toolbox(QObject, Extension):
|
||||
|
||||
@pyqtSlot(str, int)
|
||||
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)
|
||||
|
||||
self._application.getHttpRequestManager().put(url, headers_dict = self._request_headers,
|
||||
data = data.encode())
|
||||
self._application.getHttpRequestManager().put(url, data = data.encode(), scope = self._scope)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def subscribe(self, package_id: str) -> None:
|
||||
if self._application.getCuraAPI().account.isLoggedIn:
|
||||
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()
|
||||
)
|
||||
self._cloud_package_manager.subscribe(package_id)
|
||||
|
||||
@pyqtSlot(result = str)
|
||||
def getLicenseDialogPluginName(self) -> str:
|
||||
return self._license_dialog_plugin_name
|
||||
|
||||
@pyqtSlot(result = str)
|
||||
def getLicenseDialogPluginFileLocation(self) -> str:
|
||||
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:
|
||||
self._license_dialog_plugin_name = plugin_name
|
||||
self._license_dialog_license_content = license_content
|
||||
# Set page 1/1 when opening the dialog for a single package
|
||||
self._license_model.setCurrentPageIdx(0)
|
||||
self._license_model.setPageCount(1)
|
||||
|
||||
self._license_model.setPackageName(plugin_name)
|
||||
self._license_model.setLicenseText(license_content)
|
||||
self._license_dialog_plugin_file_location = plugin_file_location
|
||||
self.showLicenseDialog.emit()
|
||||
|
||||
@ -196,16 +169,6 @@ class Toolbox(QObject, Extension):
|
||||
def _onAppInitialized(self) -> None:
|
||||
self._plugin_registry = self._application.getPluginRegistry()
|
||||
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.
|
||||
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)
|
||||
|
||||
self._request_urls = {
|
||||
"authors": "{base_url}/authors".format(base_url = self._api_url),
|
||||
"packages": "{base_url}/packages".format(base_url = self._api_url),
|
||||
"authors": "{base_url}/authors".format(base_url = CloudApiModel.api_url),
|
||||
"packages": "{base_url}/packages".format(base_url = CloudApiModel.api_url),
|
||||
"updates": "{base_url}/packages/package-updates?installed_packages={query}".format(
|
||||
base_url = self._api_url, query = installed_packages_query),
|
||||
"subscribed_packages": self._api_url_user_packages,
|
||||
base_url = CloudApiModel.api_url, query = installed_packages_query)
|
||||
}
|
||||
|
||||
self._application.getCuraAPI().account.loginStateChanged.connect(self._restart)
|
||||
self._application.getCuraAPI().account.loginStateChanged.connect(self._fetchUserSubscribedPackages)
|
||||
|
||||
# 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:
|
||||
# Request the latest and greatest!
|
||||
self._makeRequestByType("updates")
|
||||
self._fetchUserSubscribedPackages()
|
||||
|
||||
|
||||
def _fetchUserSubscribedPackages(self):
|
||||
if self._application.getCuraAPI().account.isLoggedIn:
|
||||
self._makeRequestByType("subscribed_packages")
|
||||
|
||||
def _fetchPackageData(self) -> None:
|
||||
self._makeRequestByType("packages")
|
||||
self._makeRequestByType("authors")
|
||||
@ -262,7 +218,11 @@ class Toolbox(QObject, Extension):
|
||||
return None
|
||||
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:
|
||||
raise Exception("Failed to create Marketplace dialog")
|
||||
return dialog
|
||||
@ -333,13 +293,14 @@ class Toolbox(QObject, Extension):
|
||||
self.metadataChanged.emit()
|
||||
|
||||
@pyqtSlot(str)
|
||||
def install(self, file_path: str) -> None:
|
||||
self._package_manager.installPackage(file_path)
|
||||
def install(self, file_path: str) -> Optional[str]:
|
||||
package_id = self._package_manager.installPackage(file_path)
|
||||
self.installChanged.emit()
|
||||
self._updateInstalledModels()
|
||||
self.metadataChanged.emit()
|
||||
self._restart_required = True
|
||||
self.restartRequiredChanged.emit()
|
||||
return package_id
|
||||
|
||||
## Check package usage and uninstall
|
||||
# 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.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:
|
||||
container_registry = self._application.getContainerRegistry()
|
||||
|
||||
@ -560,15 +532,14 @@ class Toolbox(QObject, Extension):
|
||||
# --------------------------------------------------------------------------
|
||||
def _makeRequestByType(self, request_type: str) -> None:
|
||||
Logger.log("d", "Requesting [%s] metadata from server.", request_type)
|
||||
self._updateRequestHeader()
|
||||
url = self._request_urls[request_type]
|
||||
|
||||
callback = lambda r, rt = request_type: self._onDataRequestFinished(rt, r)
|
||||
error_callback = lambda r, e, rt = request_type: self._onDataRequestError(rt, r, e)
|
||||
self._application.getHttpRequestManager().get(url,
|
||||
headers_dict = self._request_headers,
|
||||
callback = callback,
|
||||
error_callback = error_callback)
|
||||
error_callback = error_callback,
|
||||
scope=self._scope)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def startDownload(self, url: str) -> None:
|
||||
@ -577,10 +548,12 @@ class Toolbox(QObject, Extension):
|
||||
callback = lambda r: self._onDownloadFinished(r)
|
||||
error_callback = lambda r, e: self._onDownloadFailed(r, e)
|
||||
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,
|
||||
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.setDownloadProgress(0)
|
||||
@ -652,46 +625,12 @@ class Toolbox(QObject, Extension):
|
||||
# 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]])
|
||||
self._package_manager.setPackagesWithUpdate(packages)
|
||||
elif request_type == "subscribed_packages":
|
||||
self._checkCompatibilities(json_data["data"])
|
||||
|
||||
self.metadataChanged.emit()
|
||||
|
||||
if self.isLoadingComplete():
|
||||
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
|
||||
def _notifyPackageManager(self):
|
||||
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)
|
||||
return
|
||||
|
||||
self.install(file_path)
|
||||
self.subscribe(package_info["package_id"])
|
||||
package_id = self.install(file_path)
|
||||
if package_id != package_info["package_id"]:
|
||||
Logger.error("Installed package {} does not match {}".format(package_id, package_info["package_id"]))
|
||||
self.subscribe(package_id)
|
||||
|
||||
# Getter & Setters for Properties:
|
||||
# --------------------------------------------------------------------------
|
||||
@ -773,6 +714,11 @@ class Toolbox(QObject, Extension):
|
||||
self._view_category = category
|
||||
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)
|
||||
def viewCategory(self) -> str:
|
||||
return self._view_category
|
||||
@ -792,18 +738,6 @@ class Toolbox(QObject, Extension):
|
||||
def authorsModel(self) -> AuthorsModel:
|
||||
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)
|
||||
def packagesModel(self) -> PackagesModel:
|
||||
return cast(PackagesModel, self._models["packages"])
|
||||
|
25
plugins/Toolbox/src/UltimakerCloudScope.py
Normal file
25
plugins/Toolbox/src/UltimakerCloudScope.py
Normal 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)
|
@ -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_empty: Whether the material spool is too empty to be used.
|
||||
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.compatible = compatible
|
||||
self.material_remaining = material_remaining
|
||||
|
41
resources/definitions/3dtech_semi_professional.def.json
Normal file
41
resources/definitions/3dtech_semi_professional.def.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
@ -5688,7 +5688,7 @@
|
||||
"unit": "mm",
|
||||
"enabled": "resolveOrValue('prime_tower_enable')",
|
||||
"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",
|
||||
"minimum_value": "resolveOrValue('prime_tower_size') - machine_width / 2 if machine_center_is_zero else resolveOrValue('prime_tower_size')",
|
||||
"settable_per_mesh": false,
|
||||
@ -5702,7 +5702,7 @@
|
||||
"unit": "mm",
|
||||
"enabled": "resolveOrValue('prime_tower_enable')",
|
||||
"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')",
|
||||
"minimum_value": "machine_depth / -2 if machine_center_is_zero else 0",
|
||||
"settable_per_mesh": false,
|
||||
|
@ -12,7 +12,7 @@
|
||||
"exclude_materials": [
|
||||
"chromatik_pla",
|
||||
"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",
|
||||
"fiberlogy_hd_pla",
|
||||
"filo3d_pla", "filo3d_pla_green", "filo3d_pla_red",
|
||||
@ -67,7 +67,7 @@
|
||||
"material_print_temp_wait": {"default_value": false },
|
||||
"material_bed_temp_wait": {"default_value": false },
|
||||
"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_end_gcode": {"default_value": "" },
|
||||
|
||||
|
96
resources/definitions/makeit_pro_mx.def.json
Normal file
96
resources/definitions/makeit_pro_mx.def.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -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 }
|
||||
}
|
||||
}
|
27
resources/extruders/makeit_mx_dual_1st.def.json
Normal file
27
resources/extruders/makeit_mx_dual_1st.def.json
Normal 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" }
|
||||
}
|
||||
}
|
27
resources/extruders/makeit_mx_dual_2nd.def.json
Normal file
27
resources/extruders/makeit_mx_dual_2nd.def.json
Normal 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" }
|
||||
}
|
||||
}
|
BIN
resources/meshes/3dtech_semi_professional_platform.stl
Normal file
BIN
resources/meshes/3dtech_semi_professional_platform.stl
Normal file
Binary file not shown.
@ -54,6 +54,7 @@ Item
|
||||
property alias manageProfiles: manageProfilesAction;
|
||||
|
||||
property alias manageMaterials: manageMaterialsAction;
|
||||
property alias marketplaceMaterials: marketplaceMaterialsAction;
|
||||
|
||||
property alias preferences: preferencesAction;
|
||||
|
||||
@ -188,6 +189,12 @@ Item
|
||||
shortcut: "Ctrl+K"
|
||||
}
|
||||
|
||||
Action
|
||||
{
|
||||
id: marketplaceMaterialsAction
|
||||
text: catalog.i18nc("@action:inmenu", "Add more materials from Marketplace")
|
||||
}
|
||||
|
||||
Action
|
||||
{
|
||||
id: updateProfileAction;
|
||||
|
@ -1,4 +1,4 @@
|
||||
// Copyright (c) 2019 Ultimaker B.V.
|
||||
// Copyright (c) 2020 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.7
|
||||
@ -21,7 +21,16 @@ UM.MainWindow
|
||||
id: base
|
||||
|
||||
// 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")
|
||||
|
||||
@ -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
|
||||
{
|
||||
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
|
||||
Item {
|
||||
id: mainSafeArea
|
||||
|
@ -154,7 +154,7 @@ Item
|
||||
}
|
||||
}
|
||||
|
||||
// show the plugin browser dialog
|
||||
// show the Toolbox
|
||||
Connections
|
||||
{
|
||||
target: Cura.Actions.browsePackages
|
||||
@ -163,4 +163,15 @@ Item
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
@ -157,4 +157,11 @@ Menu
|
||||
{
|
||||
action: Cura.Actions.manageMaterials
|
||||
}
|
||||
|
||||
MenuSeparator {}
|
||||
|
||||
MenuItem
|
||||
{
|
||||
action: Cura.Actions.marketplaceMaterials
|
||||
}
|
||||
}
|
||||
|
@ -520,6 +520,7 @@
|
||||
"action_button": [15.0, 2.5],
|
||||
"action_button_icon": [1.0, 1.0],
|
||||
"action_button_radius": [0.15, 0.15],
|
||||
"dialog_primary_button_padding": [3.0, 0],
|
||||
|
||||
"radio_button": [1.3, 1.3],
|
||||
|
||||
|
65
scripts/check_invalid_imports.py
Normal file
65
scripts/check_invalid_imports.py
Normal 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
5
test-in-docker.sh
Executable 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
|
Loading…
x
Reference in New Issue
Block a user