diff --git a/.gitignore b/.gitignore
index fd01d5ce76..3f7faebf5a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,5 +31,5 @@ debian*
plugins/Doodle3D-cura-plugin
plugins/GodMode
plugins/PostProcessingPlugin
-plugins/UM3NetworkPrinting
-plugins/X3GWriter
\ No newline at end of file
+plugins/X3GWriter
+
diff --git a/CMakeLists.txt b/CMakeLists.txt
index a18c4585d4..002662152e 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,4 +1,3 @@
-
project(cura NONE)
cmake_minimum_required(VERSION 2.8.12)
diff --git a/LICENSE b/LICENSE
index 3ffc567893..58777e31af 100644
--- a/LICENSE
+++ b/LICENSE
@@ -658,4 +658,4 @@ specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
-.
\ No newline at end of file
+.
diff --git a/README.md b/README.md
index e1f23bbe04..077e6d1ab7 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,8 @@ Dependencies
This will be needed at runtime to perform the actual slicing.
* [PySerial](https://github.com/pyserial/pyserial)
Only required for USB printing support.
+* [python-zeroconf](https://github.com/jstasiak/python-zeroconf)
+ Only required to detect mDNS-enabled printers
Configuring Cura
----------------
@@ -50,7 +52,7 @@ Third party plugins
Making profiles for other printers
----------------------------------
-There are two ways of doing it. You can either use the generator [here](http://quillford.github.io/CuraProfileMaker/) or you can use [this](https://github.com/Ultimaker/Cura/blob/master/resources/machines/ultimaker_original.json) as a template.
+There are two ways of doing it. You can either use the generator [here](http://quillford.github.io/CuraProfileMaker/) or you can use [this](https://github.com/Ultimaker/Cura/blob/master/resources/definitions/ultimaker_original.def.json) as a template.
* Change the machine ID to something unique
* Change the machine_name to your printer's name
@@ -62,4 +64,4 @@ There are two ways of doing it. You can either use the generator [here](http://q
* Set the start and end gcode in machine_start_gcode and machine_end_gcode
* If your printer has a heated bed, set visible to true under material_bed_temperature
-Once you are done, put the profile you have made into resources/machines, or in machines in your cura profile folder.
+Once you are done, put the profile you have made into resources/definitions, or in definitions in your cura profile folder.
diff --git a/cura/BuildVolume.py b/cura/BuildVolume.py
index 048190ff0f..e5300526d4 100644
--- a/cura/BuildVolume.py
+++ b/cura/BuildVolume.py
@@ -27,7 +27,7 @@ import UM.Settings.ContainerRegistry
# Setting for clearance around the prime
-PRIME_CLEARANCE = 1.5
+PRIME_CLEARANCE = 6.5
## Build volume is a special kind of node that is responsible for rendering the printable area & disallowed areas.
@@ -75,8 +75,8 @@ class BuildVolume(SceneNode):
self._has_errors = False
Application.getInstance().getController().getScene().sceneChanged.connect(self._onSceneChanged)
- # Number of objects loaded at the moment.
- self._number_of_objects = 0
+ #Objects loaded at the moment. We are connected to the property changed events of these objects.
+ self._scene_objects = set()
self._change_timer = QTimer()
self._change_timer.setInterval(100)
@@ -102,14 +102,33 @@ class BuildVolume(SceneNode):
def _onChangeTimerFinished(self):
root = Application.getInstance().getController().getScene().getRoot()
- new_number_of_objects = len([node for node in BreadthFirstIterator(root) if node.getMeshData() and type(node) is SceneNode])
- if new_number_of_objects != self._number_of_objects:
- recalculate = False
- if self._global_container_stack.getProperty("print_sequence", "value") == "one_at_a_time":
- recalculate = (new_number_of_objects < 2 and self._number_of_objects > 1) or (new_number_of_objects > 1 and self._number_of_objects < 2)
- self._number_of_objects = new_number_of_objects
- if recalculate:
- self._onSettingPropertyChanged("print_sequence", "value") # Create fake event, so right settings are triggered.
+ new_scene_objects = set(node for node in BreadthFirstIterator(root) if node.getMeshData() and type(node) is SceneNode)
+ if new_scene_objects != self._scene_objects:
+ for node in new_scene_objects - self._scene_objects: #Nodes that were added to the scene.
+ node.decoratorsChanged.connect(self._onNodeDecoratorChanged)
+ for node in self._scene_objects - new_scene_objects: #Nodes that were removed from the scene.
+ per_mesh_stack = node.callDecoration("getStack")
+ if per_mesh_stack:
+ per_mesh_stack.propertyChanged.disconnect(self._onSettingPropertyChanged)
+ active_extruder_changed = node.callDecoration("getActiveExtruderChangedSignal")
+ if active_extruder_changed is not None:
+ node.callDecoration("getActiveExtruderChangedSignal").disconnect(self._updateDisallowedAreasAndRebuild)
+ node.decoratorsChanged.disconnect(self._onNodeDecoratorChanged)
+
+ self._scene_objects = new_scene_objects
+ self._onSettingPropertyChanged("print_sequence", "value") # Create fake event, so right settings are triggered.
+
+ ## Updates the listeners that listen for changes in per-mesh stacks.
+ #
+ # \param node The node for which the decorators changed.
+ def _onNodeDecoratorChanged(self, node):
+ per_mesh_stack = node.callDecoration("getStack")
+ if per_mesh_stack:
+ per_mesh_stack.propertyChanged.connect(self._onSettingPropertyChanged)
+ active_extruder_changed = node.callDecoration("getActiveExtruderChangedSignal")
+ if active_extruder_changed is not None:
+ active_extruder_changed.connect(self._updateDisallowedAreasAndRebuild)
+ self._updateDisallowedAreasAndRebuild()
def setWidth(self, width):
if width: self._width = width
@@ -324,7 +343,7 @@ class BuildVolume(SceneNode):
self._width = self._global_container_stack.getProperty("machine_width", "value")
machine_height = self._global_container_stack.getProperty("machine_height", "value")
- if self._global_container_stack.getProperty("print_sequence", "value") == "one_at_a_time" and self._number_of_objects > 1:
+ if self._global_container_stack.getProperty("print_sequence", "value") == "one_at_a_time" and len(self._scene_objects) > 1:
self._height = min(self._global_container_stack.getProperty("gantry_height", "value"), machine_height)
if self._height < machine_height:
self._build_volume_message.show()
@@ -347,7 +366,7 @@ class BuildVolume(SceneNode):
rebuild_me = False
if setting_key == "print_sequence":
machine_height = self._global_container_stack.getProperty("machine_height", "value")
- if Application.getInstance().getGlobalContainerStack().getProperty("print_sequence", "value") == "one_at_a_time" and self._number_of_objects > 1:
+ if Application.getInstance().getGlobalContainerStack().getProperty("print_sequence", "value") == "one_at_a_time" and len(self._scene_objects) > 1:
self._height = min(self._global_container_stack.getProperty("gantry_height", "value"), machine_height)
if self._height < machine_height:
self._build_volume_message.show()
@@ -358,7 +377,7 @@ class BuildVolume(SceneNode):
self._build_volume_message.hide()
rebuild_me = True
- if setting_key in self._skirt_settings or setting_key in self._prime_settings or setting_key in self._tower_settings or setting_key == "print_sequence" or setting_key in self._ooze_shield_settings or setting_key in self._distance_settings:
+ if setting_key in self._skirt_settings or setting_key in self._prime_settings or setting_key in self._tower_settings or setting_key == "print_sequence" or setting_key in self._ooze_shield_settings or setting_key in self._distance_settings or setting_key in self._extruder_settings:
self._updateDisallowedAreas()
rebuild_me = True
@@ -372,6 +391,17 @@ class BuildVolume(SceneNode):
def hasErrors(self):
return self._has_errors
+ ## Calls _updateDisallowedAreas and makes sure the changes appear in the
+ # scene.
+ #
+ # This is required for a signal to trigger the update in one go. The
+ # ``_updateDisallowedAreas`` method itself shouldn't call ``rebuild``,
+ # since there may be other changes before it needs to be rebuilt, which
+ # would hit performance.
+ def _updateDisallowedAreasAndRebuild(self):
+ self._updateDisallowedAreas()
+ self.rebuild()
+
def _updateDisallowedAreas(self):
if not self._global_container_stack:
return
@@ -412,10 +442,7 @@ class BuildVolume(SceneNode):
if collision:
break
-
- if not collision:
- #Prime areas are valid. Add as normal.
- result_areas[extruder_id].extend(prime_areas[extruder_id])
+ result_areas[extruder_id].extend(prime_areas[extruder_id])
nozzle_disallowed_areas = extruder.getProperty("nozzle_disallowed_areas", "value")
for area in nozzle_disallowed_areas:
@@ -529,10 +556,10 @@ class BuildVolume(SceneNode):
for extruder in used_extruders:
extruder_id = extruder.getId()
offset_x = extruder.getProperty("machine_nozzle_offset_x", "value")
- if not offset_x:
+ if offset_x is None:
offset_x = 0
offset_y = extruder.getProperty("machine_nozzle_offset_y", "value")
- if not offset_y:
+ if offset_y is None:
offset_y = 0
result[extruder_id] = []
@@ -671,6 +698,8 @@ class BuildVolume(SceneNode):
bed_adhesion_size += value
elif adhesion_type == "raft":
bed_adhesion_size = self._getSettingFromAdhesionExtruder("raft_margin")
+ elif adhesion_type == "none":
+ bed_adhesion_size = 0
else:
raise Exception("Unknown bed adhesion type. Did you forget to update the build volume calculations for your new bed adhesion type?")
@@ -707,3 +736,4 @@ class BuildVolume(SceneNode):
_tower_settings = ["prime_tower_enable", "prime_tower_size", "prime_tower_position_x", "prime_tower_position_y"]
_ooze_shield_settings = ["ooze_shield_enabled", "ooze_shield_dist"]
_distance_settings = ["infill_wipe_dist", "travel_avoid_distance", "support_offset", "support_enable", "travel_avoid_other_parts"]
+ _extruder_settings = ["support_enable", "support_interface_enable", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_interface_extruder_nr", "brim_line_count", "adhesion_extruder_nr", "adhesion_type"] #Settings that can affect which extruders are used.
\ No newline at end of file
diff --git a/cura/ConvexHullDecorator.py b/cura/ConvexHullDecorator.py
index a7d0775e21..c4b2fe0337 100644
--- a/cura/ConvexHullDecorator.py
+++ b/cura/ConvexHullDecorator.py
@@ -236,6 +236,8 @@ class ConvexHullDecorator(SceneNodeDecorator):
extra_margin = max(0, self._getSettingProperty("raft_margin", "value"))
elif adhesion_type == "brim":
extra_margin = max(0, self._getSettingProperty("brim_line_count", "value") * self._getSettingProperty("skirt_brim_line_width", "value"))
+ elif adhesion_type == "none":
+ extra_margin = 0
elif adhesion_type == "skirt":
extra_margin = max(
0, self._getSettingProperty("skirt_gap", "value") +
diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py
index 80fee88201..e8c8ba981a 100644
--- a/cura/CuraApplication.py
+++ b/cura/CuraApplication.py
@@ -7,6 +7,7 @@ from UM.Scene.Camera import Camera
from UM.Math.Vector import Vector
from UM.Math.Quaternion import Quaternion
from UM.Math.AxisAlignedBox import AxisAlignedBox
+from UM.Math.Matrix import Matrix
from UM.Resources import Resources
from UM.Scene.ToolHandle import ToolHandle
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
@@ -130,7 +131,7 @@ class CuraApplication(QtApplication):
# For settings which are not settable_per_mesh and not settable_per_extruder:
# A function which determines the glabel/meshgroup value by looking at the values of the setting in all (used) extruders
- SettingDefinition.addSupportedProperty("resolve", DefinitionPropertyType.Function, default = None)
+ SettingDefinition.addSupportedProperty("resolve", DefinitionPropertyType.Function, default = None, depends_on = "value")
SettingDefinition.addSettingType("extruder", None, str, Validator)
@@ -379,6 +380,8 @@ class CuraApplication(QtApplication):
path = Resources.getStoragePath(self.ResourceTypes.UserInstanceContainer, file_name)
elif instance_type == "variant":
path = Resources.getStoragePath(self.ResourceTypes.VariantInstanceContainer, file_name)
+ elif instance_type == "definition_changes":
+ path = Resources.getStoragePath(self.ResourceTypes.MachineStack, file_name)
if path:
instance.setPath(path)
@@ -573,11 +576,19 @@ class CuraApplication(QtApplication):
def onSelectionChanged(self):
if Selection.hasSelection():
- if not self.getController().getActiveTool():
+ if self.getController().getActiveTool():
+ # If the tool has been disabled by the new selection
+ if not self.getController().getActiveTool().getEnabled():
+ # Default
+ self.getController().setActiveTool("TranslateTool")
+ else:
if self._previous_active_tool:
self.getController().setActiveTool(self._previous_active_tool)
+ if not self.getController().getActiveTool().getEnabled():
+ self.getController().setActiveTool("TranslateTool")
self._previous_active_tool = None
else:
+ # Default
self.getController().setActiveTool("TranslateTool")
if Preferences.getInstance().getValue("view/center_on_select"):
self._center_after_select = True
@@ -623,7 +634,7 @@ class CuraApplication(QtApplication):
if not scene_bounding_box:
scene_bounding_box = AxisAlignedBox.Null
- if repr(self._scene_bounding_box) != repr(scene_bounding_box):
+ if repr(self._scene_bounding_box) != repr(scene_bounding_box) and scene_bounding_box.isValid():
self._scene_bounding_box = scene_bounding_box
self.sceneBoundingBoxChanged.emit()
@@ -725,6 +736,8 @@ class CuraApplication(QtApplication):
continue # Node that doesnt have a mesh and is not a group.
if node.getParent() and node.getParent().callDecoration("isGroup"):
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
+ if not node.isSelectable():
+ continue # i.e. node with layer data
Selection.add(node)
## Delete all nodes containing mesh data in the scene.
@@ -764,6 +777,8 @@ class CuraApplication(QtApplication):
continue # Node that doesnt have a mesh and is not a group.
if node.getParent() and node.getParent().callDecoration("isGroup"):
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
+ if not node.isSelectable():
+ continue # i.e. node with layer data
nodes.append(node)
if nodes:
@@ -771,7 +786,11 @@ class CuraApplication(QtApplication):
for node in nodes:
# Ensure that the object is above the build platform
node.removeDecorator(ZOffsetDecorator.ZOffsetDecorator)
- op.addOperation(SetTransformOperation(node, Vector(0, node.getWorldPosition().y - node.getBoundingBox().bottom, 0)))
+ if node.getBoundingBox():
+ center_y = node.getWorldPosition().y - node.getBoundingBox().bottom
+ else:
+ center_y = 0
+ op.addOperation(SetTransformOperation(node, Vector(0, center_y, 0)))
op.push()
## Reset all transformations on nodes with mesh data.
@@ -786,6 +805,8 @@ class CuraApplication(QtApplication):
continue # Node that doesnt have a mesh and is not a group.
if node.getParent() and node.getParent().callDecoration("isGroup"):
continue # Grouped nodes don't need resetting as their parent (the group) is resetted)
+ if not node.isSelectable():
+ continue # i.e. node with layer data
nodes.append(node)
if nodes:
@@ -793,11 +814,10 @@ class CuraApplication(QtApplication):
for node in nodes:
# Ensure that the object is above the build platform
node.removeDecorator(ZOffsetDecorator.ZOffsetDecorator)
- center_y = 0
- if node.callDecoration("isGroup"):
+ if node.getBoundingBox():
center_y = node.getWorldPosition().y - node.getBoundingBox().bottom
else:
- center_y = node.getMeshData().getCenterPosition().y
+ center_y = 0
op.addOperation(SetTransformOperation(node, Vector(0, center_y, 0), Quaternion(), Vector(1, 1, 1)))
op.push()
@@ -866,8 +886,18 @@ class CuraApplication(QtApplication):
Logger.log("d", "mergeSelected: Exception:", e)
return
- # Compute the center of the objects when their origins are aligned.
- object_centers = [node.getBoundingBox().center for node in group_node.getChildren()]
+ meshes = [node.getMeshData() for node in group_node.getAllChildren() if node.getMeshData()]
+
+ # Compute the center of the objects
+ object_centers = []
+ # Forget about the translation that the original objects have
+ zero_translation = Matrix(data=numpy.zeros(3))
+ for mesh, node in zip(meshes, group_node.getChildren()):
+ transformation = node.getLocalTransformation()
+ transformation.setTranslation(zero_translation)
+ transformed_mesh = mesh.getTransformed(transformation)
+ center = transformed_mesh.getCenterPosition()
+ object_centers.append(center)
if object_centers and len(object_centers) > 0:
middle_x = sum([v.x for v in object_centers]) / len(object_centers)
middle_y = sum([v.y for v in object_centers]) / len(object_centers)
@@ -875,10 +905,16 @@ class CuraApplication(QtApplication):
offset = Vector(middle_x, middle_y, middle_z)
else:
offset = Vector(0, 0, 0)
+
# Move each node to the same position.
- for center, node in zip(object_centers, group_node.getChildren()):
- # Align the object and also apply the offset to center it inside the group.
- node.translate(-1 * (center - offset), SceneNode.TransformSpace.World)
+ for mesh, node in zip(meshes, group_node.getChildren()):
+ transformation = node.getLocalTransformation()
+ transformation.setTranslation(zero_translation)
+ transformed_mesh = mesh.getTransformed(transformation)
+
+ # Align the object around its zero position
+ # and also apply the offset to center it inside the group.
+ node.setPosition(-transformed_mesh.getZeroPosition() - offset)
# Use the previously found center of the group bounding box as the new location of the group
group_node.setPosition(group_node.getBoundingBox().center)
diff --git a/cura/PrinterOutputDevice.py b/cura/PrinterOutputDevice.py
index 6eae259e1e..6e7305b27b 100644
--- a/cura/PrinterOutputDevice.py
+++ b/cura/PrinterOutputDevice.py
@@ -49,7 +49,9 @@ class PrinterOutputDevice(QObject, OutputDevice):
self._printer_state = ""
self._printer_type = "unknown"
- def requestWrite(self, nodes, file_name = None, filter_by_machine = False):
+ self._camera_active = False
+
+ def requestWrite(self, nodes, file_name = None, filter_by_machine = False, file_handler = None):
raise NotImplementedError("requestWrite needs to be implemented")
## Signals
@@ -136,6 +138,7 @@ class PrinterOutputDevice(QObject, OutputDevice):
@pyqtSlot()
def startCamera(self):
+ self._camera_active = True
self._startCamera()
def _startCamera(self):
@@ -143,6 +146,7 @@ class PrinterOutputDevice(QObject, OutputDevice):
@pyqtSlot()
def stopCamera(self):
+ self._camera_active = False
self._stopCamera()
def _stopCamera(self):
diff --git a/cura/Settings/CuraContainerRegistry.py b/cura/Settings/CuraContainerRegistry.py
index 98c37da617..3982418070 100644
--- a/cura/Settings/CuraContainerRegistry.py
+++ b/cura/Settings/CuraContainerRegistry.py
@@ -210,7 +210,7 @@ class CuraContainerRegistry(ContainerRegistry):
return {"status": "ok", "message": catalog.i18nc("@info:status", "Successfully imported profile {0}", profile_or_list[0].getName())}
# If it hasn't returned by now, none of the plugins loaded the profile successfully.
- return {"status": "error", "message": catalog.i18nc("@info:status", "Profile {0} has an unknown file type.", file_name)}
+ return {"status": "error", "message": catalog.i18nc("@info:status", "Profile {0} has an unknown file type or is corrupted.", file_name)}
def _configureProfile(self, profile, id_seed, new_name):
profile.setReadOnly(False)
diff --git a/cura/Settings/ExtruderManager.py b/cura/Settings/ExtruderManager.py
index 5517830141..4d4f66652e 100644
--- a/cura/Settings/ExtruderManager.py
+++ b/cura/Settings/ExtruderManager.py
@@ -152,6 +152,18 @@ class ExtruderManager(QObject):
if changed:
self.extrudersChanged.emit(machine_id)
+ def registerExtruder(self, extruder_train, machine_id):
+ changed = False
+
+ if machine_id not in self._extruder_trains:
+ self._extruder_trains[machine_id] = {}
+ changed = True
+ if extruder_train:
+ self._extruder_trains[machine_id][extruder_train.getMetaDataEntry("position")] = extruder_train
+ changed = True
+ if changed:
+ self.extrudersChanged.emit(machine_id)
+
## Creates a container stack for an extruder train.
#
# The container stack has an extruder definition at the bottom, which is
@@ -336,8 +348,8 @@ class ExtruderManager(QObject):
if support_interface_enabled:
used_extruder_stack_ids.add(self.extruderIds[str(global_stack.getProperty("support_interface_extruder_nr", "value"))])
- #The platform adhesion extruder. Not used if using brim and brim width is 0.
- if global_stack.getProperty("adhesion_type", "value") != "brim" or global_stack.getProperty("brim_line_count", "value") > 0:
+ #The platform adhesion extruder. Not used if using none.
+ if global_stack.getProperty("adhesion_type", "value") != "none":
used_extruder_stack_ids.add(self.extruderIds[str(global_stack.getProperty("adhesion_extruder_nr", "value"))])
return [container_registry.findContainerStacks(id = stack_id)[0] for stack_id in used_extruder_stack_ids]
diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py
index fce82212cd..94766b23bd 100644
--- a/cura/Settings/MachineManager.py
+++ b/cura/Settings/MachineManager.py
@@ -246,6 +246,7 @@ class MachineManager(QObject):
quality = self._global_container_stack.findContainer({"type": "quality"})
quality.nameChanged.connect(self._onQualityNameChanged)
+ self._updateStacksHaveErrors()
## Update self._stacks_valid according to _checkStacksForErrors and emit if change.
def _updateStacksHaveErrors(self):
@@ -277,7 +278,7 @@ class MachineManager(QObject):
def _onInstanceContainersChanged(self, container):
container_type = container.getMetaDataEntry("type")
-
+
if container_type == "material":
self.activeMaterialChanged.emit()
elif container_type == "variant":
@@ -285,6 +286,8 @@ class MachineManager(QObject):
elif container_type == "quality":
self.activeQualityChanged.emit()
+ self._updateStacksHaveErrors()
+
def _onPropertyChanged(self, key, property_name):
if property_name == "value":
# Notify UI items, such as the "changed" star in profile pull down menu.
@@ -297,6 +300,16 @@ class MachineManager(QObject):
changed_validation_state = self._active_container_stack.getProperty(key, property_name)
else:
changed_validation_state = self._global_container_stack.getProperty(key, property_name)
+
+ if changed_validation_state is None:
+ # Setting is not validated. This can happen if there is only a setting definition.
+ # We do need to validate it, because a setting defintions value can be set by a function, which could
+ # be an invalid setting.
+ definition = self._active_container_stack.getSettingDefinition(key)
+ validator_type = UM.Settings.SettingDefinition.getValidatorForType(definition.type)
+ if validator_type:
+ validator = validator_type(key)
+ changed_validation_state = validator(self._active_container_stack)
if changed_validation_state in (UM.Settings.ValidatorState.Exception, UM.Settings.ValidatorState.MaximumError, UM.Settings.ValidatorState.MinimumError):
self._stacks_have_errors = True
self.stacksValidationChanged.emit()
@@ -871,7 +884,7 @@ class MachineManager(QObject):
def _askUserToKeepOrClearCurrentSettings(self):
# Ask the user if the user profile should be cleared or not (discarding the current settings)
# In Simple Mode we assume the user always wants to keep the (limited) current settings
- details_text = catalog.i18nc("@label", "You made changes to the following setting(s):")
+ details_text = catalog.i18nc("@label", "You made changes to the following setting(s)/override(s):")
# user changes in global stack
details_list = [setting.definition.label for setting in self._global_container_stack.getTop().findInstances(**{})]
@@ -886,14 +899,19 @@ class MachineManager(QObject):
# Format to output string
details = "\n ".join([details_text, ] + details_list)
- Application.getInstance().messageBox(catalog.i18nc("@window:title", "Switched profiles"),
- catalog.i18nc("@label",
- "Do you want to transfer your changed settings to this profile?"),
- catalog.i18nc("@label",
- "If you transfer your settings they will override settings in the profile."),
- details,
- buttons=QMessageBox.Yes + QMessageBox.No, icon=QMessageBox.Question,
- callback=self._keepUserSettingsDialogCallback)
+ num_changed_settings = len(details_list)
+ Application.getInstance().messageBox(
+ catalog.i18nc("@window:title", "Switched profiles"),
+ catalog.i18nc(
+ "@label",
+ "Do you want to transfer your %d changed setting(s)/override(s) to this profile?") % num_changed_settings,
+ catalog.i18nc(
+ "@label",
+ "If you transfer your settings they will override settings in the profile. If you don't transfer these settings, they will be lost."),
+ details,
+ buttons=QMessageBox.Yes + QMessageBox.No,
+ icon=QMessageBox.Question,
+ callback=self._keepUserSettingsDialogCallback)
def _keepUserSettingsDialogCallback(self, button):
if button == QMessageBox.Yes:
diff --git a/cura/Settings/QualitySettingsModel.py b/cura/Settings/QualitySettingsModel.py
index 07191cd49d..fc523cf13d 100644
--- a/cura/Settings/QualitySettingsModel.py
+++ b/cura/Settings/QualitySettingsModel.py
@@ -178,6 +178,12 @@ class QualitySettingsModel(UM.Qt.ListModel.ListModel):
profile_value_source = container.getMetaDataEntry("type")
profile_value = new_value
+ # Global tab should use resolve (if there is one)
+ if not self._extruder_id:
+ resolve_value = global_container_stack.getProperty(definition.key, "resolve")
+ if resolve_value is not None and profile_value is not None:
+ profile_value = resolve_value
+
user_value = None
if not self._extruder_id:
user_value = global_container_stack.getTop().getProperty(definition.key, "value")
diff --git a/cura/Settings/SettingInheritanceManager.py b/cura/Settings/SettingInheritanceManager.py
index 4d1e60a739..b13faadd0e 100644
--- a/cura/Settings/SettingInheritanceManager.py
+++ b/cura/Settings/SettingInheritanceManager.py
@@ -85,7 +85,7 @@ class SettingInheritanceManager(QObject):
self._update() # Ensure that the settings_with_inheritance_warning list is populated.
def _onPropertyChanged(self, key, property_name):
- if property_name == "value" and self._global_container_stack:
+ if (property_name == "value" or property_name == "enabled") and self._global_container_stack:
definitions = self._global_container_stack.getBottom().findDefinitions(key = key)
if not definitions:
return
@@ -167,7 +167,16 @@ class SettingInheritanceManager(QObject):
continue
if value is not None:
# If a setting doesn't use any keys, it won't change it's value, so treat it as if it's a fixed value
- has_setting_function = isinstance(value, UM.Settings.SettingFunction) and len(value.getUsedSettingKeys()) > 0
+ has_setting_function = isinstance(value, UM.Settings.SettingFunction)
+ if has_setting_function:
+ for setting_key in value.getUsedSettingKeys():
+ if setting_key in self._active_container_stack.getAllKeys():
+ break # We found an actual setting. So has_setting_function can remain true
+ else:
+ # All of the setting_keys turned out to not be setting keys at all!
+ # This can happen due enum keys also being marked as settings.
+ has_setting_function = False
+
if has_setting_function is False:
has_non_function_value = True
continue
diff --git a/cura/Settings/SettingOverrideDecorator.py b/cura/Settings/SettingOverrideDecorator.py
index a8cfcd8d80..d38dac565b 100644
--- a/cura/Settings/SettingOverrideDecorator.py
+++ b/cura/Settings/SettingOverrideDecorator.py
@@ -61,6 +61,12 @@ class SettingOverrideDecorator(SceneNodeDecorator):
def getActiveExtruder(self):
return self._extruder_stack
+ ## Gets the signal that emits if the active extruder changed.
+ #
+ # This can then be accessed via a decorator.
+ def getActiveExtruderChangedSignal(self):
+ return self.activeExtruderChanged
+
## Gets the currently active extruders position
#
# \return An extruder's position, or None if no position info is available.
diff --git a/plugins/3MFReader/ThreeMFReader.py b/plugins/3MFReader/ThreeMFReader.py
index 2aa719018d..3fee1adcb8 100644
--- a/plugins/3MFReader/ThreeMFReader.py
+++ b/plugins/3MFReader/ThreeMFReader.py
@@ -84,20 +84,20 @@ class ThreeMFReader(MeshReader):
definition = QualityManager.getInstance().getParentMachineDefinition(global_container_stack.getBottom())
node.callDecoration("getStack").getTop().setDefinition(definition)
- setting_container = node.callDecoration("getStack").getTop()
- for setting in xml_settings:
- setting_key = setting.get("key")
- setting_value = setting.text
+ setting_container = node.callDecoration("getStack").getTop()
+ for setting in xml_settings:
+ setting_key = setting.get("key")
+ setting_value = setting.text
- # Extruder_nr is a special case.
- if setting_key == "extruder_nr":
- extruder_stack = ExtruderManager.getInstance().getExtruderStack(int(setting_value))
- if extruder_stack:
- node.callDecoration("setActiveExtruder", extruder_stack.getId())
- else:
- Logger.log("w", "Unable to find extruder in position %s", setting_value)
- continue
- setting_container.setProperty(setting_key,"value", setting_value)
+ # Extruder_nr is a special case.
+ if setting_key == "extruder_nr":
+ extruder_stack = ExtruderManager.getInstance().getExtruderStack(int(setting_value))
+ if extruder_stack:
+ node.callDecoration("setActiveExtruder", extruder_stack.getId())
+ else:
+ Logger.log("w", "Unable to find extruder in position %s", setting_value)
+ continue
+ setting_container.setProperty(setting_key,"value", setting_value)
if len(node.getChildren()) > 0:
group_decorator = GroupDecorator()
diff --git a/plugins/3MFReader/ThreeMFWorkspaceReader.py b/plugins/3MFReader/ThreeMFWorkspaceReader.py
new file mode 100644
index 0000000000..e1f0173235
--- /dev/null
+++ b/plugins/3MFReader/ThreeMFWorkspaceReader.py
@@ -0,0 +1,405 @@
+from UM.Workspace.WorkspaceReader import WorkspaceReader
+from UM.Application import Application
+
+from UM.Logger import Logger
+from UM.i18n import i18nCatalog
+from UM.Settings.ContainerStack import ContainerStack
+from UM.Settings.DefinitionContainer import DefinitionContainer
+from UM.Settings.InstanceContainer import InstanceContainer
+from UM.Settings.ContainerRegistry import ContainerRegistry
+from UM.MimeTypeDatabase import MimeTypeDatabase
+from UM.Job import Job
+from UM.Preferences import Preferences
+from .WorkspaceDialog import WorkspaceDialog
+
+from cura.Settings.ExtruderManager import ExtruderManager
+
+import zipfile
+import io
+import configparser
+
+i18n_catalog = i18nCatalog("cura")
+
+
+## Base implementation for reading 3MF workspace files.
+class ThreeMFWorkspaceReader(WorkspaceReader):
+ def __init__(self):
+ super().__init__()
+ self._supported_extensions = [".3mf"]
+ self._dialog = WorkspaceDialog()
+ self._3mf_mesh_reader = None
+ self._container_registry = ContainerRegistry.getInstance()
+ self._definition_container_suffix = ContainerRegistry.getMimeTypeForContainer(DefinitionContainer).preferredSuffix
+ self._material_container_suffix = None # We have to wait until all other plugins are loaded before we can set it
+ self._instance_container_suffix = ContainerRegistry.getMimeTypeForContainer(InstanceContainer).preferredSuffix
+ self._container_stack_suffix = ContainerRegistry.getMimeTypeForContainer(ContainerStack).preferredSuffix
+
+ self._resolve_strategies = {}
+
+ self._id_mapping = {}
+
+ ## Get a unique name based on the old_id. This is different from directly calling the registry in that it caches results.
+ # This has nothing to do with speed, but with getting consistent new naming for instances & objects.
+ def getNewId(self, old_id):
+ if old_id not in self._id_mapping:
+ self._id_mapping[old_id] = self._container_registry.uniqueName(old_id)
+ return self._id_mapping[old_id]
+
+ def preRead(self, file_name):
+ self._3mf_mesh_reader = Application.getInstance().getMeshFileHandler().getReaderForFile(file_name)
+ if self._3mf_mesh_reader and self._3mf_mesh_reader.preRead(file_name) == WorkspaceReader.PreReadResult.accepted:
+ pass
+ else:
+ Logger.log("w", "Could not find reader that was able to read the scene data for 3MF workspace")
+ return WorkspaceReader.PreReadResult.failed
+
+ # Check if there are any conflicts, so we can ask the user.
+ archive = zipfile.ZipFile(file_name, "r")
+ cura_file_names = [name for name in archive.namelist() if name.startswith("Cura/")]
+ container_stack_files = [name for name in cura_file_names if name.endswith(self._container_stack_suffix)]
+ self._resolve_strategies = {"machine": None, "quality_changes": None, "material": None}
+ machine_conflict = False
+ quality_changes_conflict = False
+ for container_stack_file in container_stack_files:
+ container_id = self._stripFileToId(container_stack_file)
+ stacks = self._container_registry.findContainerStacks(id=container_id)
+ if stacks:
+ # Check if there are any changes at all in any of the container stacks.
+ id_list = self._getContainerIdListFromSerialized(archive.open(container_stack_file).read().decode("utf-8"))
+ for index, container_id in enumerate(id_list):
+ if stacks[0].getContainer(index).getId() != container_id:
+ machine_conflict = True
+ break
+ Job.yieldThread()
+
+ material_conflict = False
+ xml_material_profile = self._getXmlProfileClass()
+ if self._material_container_suffix is None:
+ self._material_container_suffix = ContainerRegistry.getMimeTypeForContainer(xml_material_profile).preferredSuffix
+ if xml_material_profile:
+ material_container_files = [name for name in cura_file_names if name.endswith(self._material_container_suffix)]
+ for material_container_file in material_container_files:
+ container_id = self._stripFileToId(material_container_file)
+ materials = self._container_registry.findInstanceContainers(id=container_id)
+ if materials and not materials[0].isReadOnly(): # Only non readonly materials can be in conflict
+ material_conflict = True
+ Job.yieldThread()
+
+ # Check if any quality_changes instance container is in conflict.
+ instance_container_files = [name for name in cura_file_names if name.endswith(self._instance_container_suffix)]
+ for instance_container_file in instance_container_files:
+ container_id = self._stripFileToId(instance_container_file)
+ instance_container = InstanceContainer(container_id)
+
+ # Deserialize InstanceContainer by converting read data from bytes to string
+ instance_container.deserialize(archive.open(instance_container_file).read().decode("utf-8"))
+ container_type = instance_container.getMetaDataEntry("type")
+ if container_type == "quality_changes":
+ # Check if quality changes already exists.
+ quality_changes = self._container_registry.findInstanceContainers(id = container_id)
+ if quality_changes:
+ # Check if there really is a conflict by comparing the values
+ if quality_changes[0] != instance_container:
+ quality_changes_conflict = True
+ break
+ Job.yieldThread()
+ try:
+ archive.open("Cura/preferences.cfg")
+ except KeyError:
+ # If there is no preferences file, it's not a workspace, so notify user of failure.
+ Logger.log("w", "File %s is not a valid workspace.", file_name)
+ return WorkspaceReader.PreReadResult.failed
+
+ if machine_conflict or quality_changes_conflict or material_conflict:
+ # There is a conflict; User should choose to either update the existing data, add everything as new data or abort
+ self._dialog.setMachineConflict(machine_conflict)
+ self._dialog.setQualityChangesConflict(quality_changes_conflict)
+ self._dialog.setMaterialConflict(material_conflict)
+ self._dialog.show()
+
+ # Block until the dialog is closed.
+ self._dialog.waitForClose()
+
+ if self._dialog.getResult() == {}:
+ return WorkspaceReader.PreReadResult.cancelled
+
+ self._resolve_strategies = self._dialog.getResult()
+
+ return WorkspaceReader.PreReadResult.accepted
+
+ def read(self, file_name):
+ # Load all the nodes / meshdata of the workspace
+ nodes = self._3mf_mesh_reader.read(file_name)
+ if nodes is None:
+ nodes = []
+
+ archive = zipfile.ZipFile(file_name, "r")
+
+ cura_file_names = [name for name in archive.namelist() if name.startswith("Cura/")]
+
+ # Create a shadow copy of the preferences (we don't want all of the preferences, but we do want to re-use its
+ # parsing code.
+ temp_preferences = Preferences()
+ temp_preferences.readFromFile(io.TextIOWrapper(archive.open("Cura/preferences.cfg"))) # We need to wrap it, else the archive parser breaks.
+
+ # Copy a number of settings from the temp preferences to the global
+ global_preferences = Preferences.getInstance()
+ global_preferences.setValue("general/visible_settings", temp_preferences.getValue("general/visible_settings"))
+ global_preferences.setValue("cura/categories_expanded", temp_preferences.getValue("cura/categories_expanded"))
+ Application.getInstance().expandedCategoriesChanged.emit() # Notify the GUI of the change
+
+ self._id_mapping = {}
+
+ # We don't add containers right away, but wait right until right before the stack serialization.
+ # We do this so that if something goes wrong, it's easier to clean up.
+ containers_to_add = []
+
+ # TODO: For the moment we use pretty naive existence checking. If the ID is the same, we assume in quite a few
+ # TODO: cases that the container loaded is the same (most notable in materials & definitions).
+ # TODO: It might be possible that we need to add smarter checking in the future.
+ Logger.log("d", "Workspace loading is checking definitions...")
+ # Get all the definition files & check if they exist. If not, add them.
+ definition_container_files = [name for name in cura_file_names if name.endswith(self._definition_container_suffix)]
+ for definition_container_file in definition_container_files:
+ container_id = self._stripFileToId(definition_container_file)
+ definitions = self._container_registry.findDefinitionContainers(id=container_id)
+ if not definitions:
+ definition_container = DefinitionContainer(container_id)
+ definition_container.deserialize(archive.open(definition_container_file).read().decode("utf-8"))
+ self._container_registry.addContainer(definition_container)
+ Job.yieldThread()
+
+ Logger.log("d", "Workspace loading is checking materials...")
+ material_containers = []
+ # Get all the material files and check if they exist. If not, add them.
+ xml_material_profile = self._getXmlProfileClass()
+ if self._material_container_suffix is None:
+ self._material_container_suffix = ContainerRegistry.getMimeTypeForContainer(xml_material_profile).suffixes[0]
+ if xml_material_profile:
+ material_container_files = [name for name in cura_file_names if name.endswith(self._material_container_suffix)]
+ for material_container_file in material_container_files:
+ container_id = self._stripFileToId(material_container_file)
+ materials = self._container_registry.findInstanceContainers(id=container_id)
+ if not materials:
+ material_container = xml_material_profile(container_id)
+ material_container.deserialize(archive.open(material_container_file).read().decode("utf-8"))
+ containers_to_add.append(material_container)
+ else:
+ if not materials[0].isReadOnly(): # Only create new materials if they are not read only.
+ if self._resolve_strategies["material"] == "override":
+ materials[0].deserialize(archive.open(material_container_file).read().decode("utf-8"))
+ elif self._resolve_strategies["material"] == "new":
+ # Note that we *must* deserialize it with a new ID, as multiple containers will be
+ # auto created & added.
+ material_container = xml_material_profile(self.getNewId(container_id))
+ material_container.deserialize(archive.open(material_container_file).read().decode("utf-8"))
+ containers_to_add.append(material_container)
+ material_containers.append(material_container)
+ Job.yieldThread()
+
+ Logger.log("d", "Workspace loading is checking instance containers...")
+ # Get quality_changes and user profiles saved in the workspace
+ instance_container_files = [name for name in cura_file_names if name.endswith(self._instance_container_suffix)]
+ user_instance_containers = []
+ quality_changes_instance_containers = []
+ for instance_container_file in instance_container_files:
+ container_id = self._stripFileToId(instance_container_file)
+ instance_container = InstanceContainer(container_id)
+
+ # Deserialize InstanceContainer by converting read data from bytes to string
+ instance_container.deserialize(archive.open(instance_container_file).read().decode("utf-8"))
+ container_type = instance_container.getMetaDataEntry("type")
+ Job.yieldThread()
+ if container_type == "user":
+ # Check if quality changes already exists.
+ user_containers = self._container_registry.findInstanceContainers(id=container_id)
+ if not user_containers:
+ containers_to_add.append(instance_container)
+ else:
+ if self._resolve_strategies["machine"] == "override" or self._resolve_strategies["machine"] is None:
+ user_containers[0].deserialize(archive.open(instance_container_file).read().decode("utf-8"))
+ elif self._resolve_strategies["machine"] == "new":
+ # The machine is going to get a spiffy new name, so ensure that the id's of user settings match.
+ extruder_id = instance_container.getMetaDataEntry("extruder", None)
+ if extruder_id:
+ new_id = self.getNewId(extruder_id) + "_current_settings"
+ instance_container._id = new_id
+ instance_container.setName(new_id)
+ instance_container.setMetaDataEntry("extruder", self.getNewId(extruder_id))
+ containers_to_add.append(instance_container)
+
+ machine_id = instance_container.getMetaDataEntry("machine", None)
+ if machine_id:
+ new_id = self.getNewId(machine_id) + "_current_settings"
+ instance_container._id = new_id
+ instance_container.setName(new_id)
+ instance_container.setMetaDataEntry("machine", self.getNewId(machine_id))
+ containers_to_add.append(instance_container)
+ user_instance_containers.append(instance_container)
+ elif container_type == "quality_changes":
+ # Check if quality changes already exists.
+ quality_changes = self._container_registry.findInstanceContainers(id = container_id)
+ if not quality_changes:
+ containers_to_add.append(instance_container)
+ else:
+ if self._resolve_strategies["quality_changes"] == "override":
+ quality_changes[0].deserialize(archive.open(instance_container_file).read().decode("utf-8"))
+ elif self._resolve_strategies["quality_changes"] is None:
+ # The ID already exists, but nothing in the values changed, so do nothing.
+ pass
+ quality_changes_instance_containers.append(instance_container)
+ else:
+ continue
+
+ # Add all the containers right before we try to add / serialize the stack
+ for container in containers_to_add:
+ self._container_registry.addContainer(container)
+
+ # Get the stack(s) saved in the workspace.
+ Logger.log("d", "Workspace loading is checking stacks containers...")
+ container_stack_files = [name for name in cura_file_names if name.endswith(self._container_stack_suffix)]
+ global_stack = None
+ extruder_stacks = []
+ container_stacks_added = []
+ try:
+ for container_stack_file in container_stack_files:
+ container_id = self._stripFileToId(container_stack_file)
+
+ # Check if a stack by this ID already exists;
+ container_stacks = self._container_registry.findContainerStacks(id=container_id)
+ if container_stacks:
+ stack = container_stacks[0]
+ if self._resolve_strategies["machine"] == "override":
+ container_stacks[0].deserialize(archive.open(container_stack_file).read().decode("utf-8"))
+ elif self._resolve_strategies["machine"] == "new":
+ new_id = self.getNewId(container_id)
+ stack = ContainerStack(new_id)
+ stack.deserialize(archive.open(container_stack_file).read().decode("utf-8"))
+
+ # Ensure a unique ID and name
+ stack._id = new_id
+
+ # Extruder stacks are "bound" to a machine. If we add the machine as a new one, the id of the
+ # bound machine also needs to change.
+ if stack.getMetaDataEntry("machine", None):
+ stack.setMetaDataEntry("machine", self.getNewId(stack.getMetaDataEntry("machine")))
+
+ if stack.getMetaDataEntry("type") != "extruder_train":
+ # Only machines need a new name, stacks may be non-unique
+ stack.setName(self._container_registry.uniqueName(stack.getName()))
+ container_stacks_added.append(stack)
+ self._container_registry.addContainer(stack)
+ else:
+ Logger.log("w", "Resolve strategy of %s for machine is not supported", self._resolve_strategies["machine"])
+ else:
+ stack = ContainerStack(container_id)
+ # Deserialize stack by converting read data from bytes to string
+ stack.deserialize(archive.open(container_stack_file).read().decode("utf-8"))
+ container_stacks_added.append(stack)
+ self._container_registry.addContainer(stack)
+
+ if stack.getMetaDataEntry("type") == "extruder_train":
+ extruder_stacks.append(stack)
+ else:
+ global_stack = stack
+ Job.yieldThread()
+ except:
+ Logger.log("W", "We failed to serialize the stack. Trying to clean up.")
+ # Something went really wrong. Try to remove any data that we added.
+ for container in containers_to_add:
+ self._container_registry.getInstance().removeContainer(container.getId())
+
+ for container in container_stacks_added:
+ self._container_registry.getInstance().removeContainer(container.getId())
+
+ return None
+
+ if self._resolve_strategies["machine"] == "new":
+ # A new machine was made, but it was serialized with the wrong user container. Fix that now.
+ for container in user_instance_containers:
+ extruder_id = container.getMetaDataEntry("extruder", None)
+ if extruder_id:
+ for extruder in extruder_stacks:
+ if extruder.getId() == extruder_id:
+ extruder.replaceContainer(0, container)
+ continue
+ machine_id = container.getMetaDataEntry("machine", None)
+ if machine_id:
+ if global_stack.getId() == machine_id:
+ global_stack.replaceContainer(0, container)
+ continue
+
+ if self._resolve_strategies["quality_changes"] == "new":
+ # Quality changes needs to get a new ID, added to registry and to the right stacks
+ for container in quality_changes_instance_containers:
+ old_id = container.getId()
+ container.setName(self._container_registry.uniqueName(container.getName()))
+ # We're not really supposed to change the ID in normal cases, but this is an exception.
+ container._id = self.getNewId(container.getId())
+
+ # The container was not added yet, as it didn't have an unique ID. It does now, so add it.
+ self._container_registry.addContainer(container)
+
+ # Replace the quality changes container
+ old_container = global_stack.findContainer({"type": "quality_changes"})
+ if old_container.getId() == old_id:
+ quality_changes_index = global_stack.getContainerIndex(old_container)
+ global_stack.replaceContainer(quality_changes_index, container)
+ continue
+
+ for stack in extruder_stacks:
+ old_container = stack.findContainer({"type": "quality_changes"})
+ if old_container.getId() == old_id:
+ quality_changes_index = stack.getContainerIndex(old_container)
+ stack.replaceContainer(quality_changes_index, container)
+
+ if self._resolve_strategies["material"] == "new":
+ for material in material_containers:
+ old_material = global_stack.findContainer({"type": "material"})
+ if old_material.getId() in self._id_mapping:
+ material_index = global_stack.getContainerIndex(old_material)
+ global_stack.replaceContainer(material_index, material)
+ continue
+
+ for stack in extruder_stacks:
+ old_material = stack.findContainer({"type": "material"})
+ if old_material.getId() in self._id_mapping:
+ material_index = stack.getContainerIndex(old_material)
+ stack.replaceContainer(material_index, material)
+ continue
+
+ for stack in extruder_stacks:
+ ExtruderManager.getInstance().registerExtruder(stack, global_stack.getId())
+ else:
+ # Machine has no extruders, but it needs to be registered with the extruder manager.
+ ExtruderManager.getInstance().registerExtruder(None, global_stack.getId())
+
+ Logger.log("d", "Workspace loading is notifying rest of the code of changes...")
+ # Notify everything/one that is to notify about changes.
+ for container in global_stack.getContainers():
+ global_stack.containersChanged.emit(container)
+
+ Job.yieldThread()
+ for stack in extruder_stacks:
+ stack.setNextStack(global_stack)
+ for container in stack.getContainers():
+ stack.containersChanged.emit(container)
+ Job.yieldThread()
+
+ # Actually change the active machine.
+ Application.getInstance().setGlobalContainerStack(global_stack)
+ return nodes
+
+ def _stripFileToId(self, file):
+ return file.replace("Cura/", "").split(".")[0]
+
+ def _getXmlProfileClass(self):
+ return self._container_registry.getContainerForMimeType(MimeTypeDatabase.getMimeType("application/x-ultimaker-material-profile"))
+
+ ## Get the list of ID's of all containers in a container stack by partially parsing it's serialized data.
+ def _getContainerIdListFromSerialized(self, serialized):
+ parser = configparser.ConfigParser(interpolation=None, empty_lines_in_values=False)
+ parser.read_string(serialized)
+ container_string = parser["general"].get("containers", "")
+ container_list = container_string.split(",")
+ return [container_id for container_id in container_list if container_id != ""]
diff --git a/plugins/3MFReader/WorkspaceDialog.py b/plugins/3MFReader/WorkspaceDialog.py
new file mode 100644
index 0000000000..bf9dce8264
--- /dev/null
+++ b/plugins/3MFReader/WorkspaceDialog.py
@@ -0,0 +1,134 @@
+from PyQt5.QtCore import Qt, QUrl, pyqtSignal, pyqtSlot, QObject, pyqtProperty, QCoreApplication
+from PyQt5.QtQml import QQmlComponent, QQmlContext
+from UM.PluginRegistry import PluginRegistry
+from UM.Application import Application
+from UM.Logger import Logger
+
+import os
+import threading
+import time
+
+class WorkspaceDialog(QObject):
+ showDialogSignal = pyqtSignal()
+
+ def __init__(self, parent = None):
+ super().__init__(parent)
+ self._component = None
+ self._context = None
+ self._view = None
+ self._qml_url = "WorkspaceDialog.qml"
+ self._lock = threading.Lock()
+ self._default_strategy = "override"
+ self._result = {"machine": self._default_strategy,
+ "quality_changes": self._default_strategy,
+ "material": self._default_strategy}
+ self._visible = False
+ self.showDialogSignal.connect(self.__show)
+
+ self._has_quality_changes_conflict = False
+ self._has_machine_conflict = False
+ self._has_material_conflict = False
+
+ machineConflictChanged = pyqtSignal()
+ qualityChangesConflictChanged = pyqtSignal()
+ materialConflictChanged = pyqtSignal()
+
+ @pyqtProperty(bool, notify = machineConflictChanged)
+ def machineConflict(self):
+ return self._has_machine_conflict
+
+ @pyqtProperty(bool, notify=qualityChangesConflictChanged)
+ def qualityChangesConflict(self):
+ return self._has_quality_changes_conflict
+
+ @pyqtProperty(bool, notify=materialConflictChanged)
+ def materialConflict(self):
+ return self._has_material_conflict
+
+ @pyqtSlot(str, str)
+ def setResolveStrategy(self, key, strategy):
+ if key in self._result:
+ self._result[key] = strategy
+
+ def setMaterialConflict(self, material_conflict):
+ self._has_material_conflict = material_conflict
+ self.materialConflictChanged.emit()
+
+ def setMachineConflict(self, machine_conflict):
+ self._has_machine_conflict = machine_conflict
+ self.machineConflictChanged.emit()
+
+ def setQualityChangesConflict(self, quality_changes_conflict):
+ self._has_quality_changes_conflict = quality_changes_conflict
+ self.qualityChangesConflictChanged.emit()
+
+ def getResult(self):
+ if "machine" in self._result and not self._has_machine_conflict:
+ self._result["machine"] = None
+ if "quality_changes" in self._result and not self._has_quality_changes_conflict:
+ self._result["quality_changes"] = None
+ if "material" in self._result and not self._has_material_conflict:
+ self._result["material"] = None
+ return self._result
+
+ def _createViewFromQML(self):
+ path = QUrl.fromLocalFile(os.path.join(PluginRegistry.getInstance().getPluginPath("3MFReader"), self._qml_url))
+ self._component = QQmlComponent(Application.getInstance()._engine, path)
+ self._context = QQmlContext(Application.getInstance()._engine.rootContext())
+ self._context.setContextProperty("manager", self)
+ self._view = self._component.create(self._context)
+ if self._view is None:
+ Logger.log("c", "QQmlComponent status %s", self._component.status())
+ Logger.log("c", "QQmlComponent error string %s", self._component.errorString())
+
+ def show(self):
+ # Emit signal so the right thread actually shows the view.
+ if threading.current_thread() != threading.main_thread():
+ self._lock.acquire()
+ # Reset the result
+ self._result = {"machine": self._default_strategy,
+ "quality_changes": self._default_strategy,
+ "material": self._default_strategy}
+ self._visible = True
+ self.showDialogSignal.emit()
+
+ @pyqtSlot()
+ ## Used to notify the dialog so the lock can be released.
+ def notifyClosed(self):
+ if self._result is None:
+ self._result = {}
+ self._lock.release()
+
+ def hide(self):
+ self._visible = False
+ self._lock.release()
+ self._view.hide()
+
+ @pyqtSlot()
+ def onOkButtonClicked(self):
+ self._view.hide()
+ self.hide()
+
+ @pyqtSlot()
+ def onCancelButtonClicked(self):
+ self._view.hide()
+ self.hide()
+ self._result = {}
+
+ ## Block thread until the dialog is closed.
+ def waitForClose(self):
+ if self._visible:
+ if threading.current_thread() != threading.main_thread():
+ self._lock.acquire()
+ self._lock.release()
+ else:
+ # If this is not run from a separate thread, we need to ensure that the events are still processed.
+ while self._visible:
+ time.sleep(1 / 50)
+ QCoreApplication.processEvents() # Ensure that the GUI does not freeze.
+
+ def __show(self):
+ if self._view is None:
+ self._createViewFromQML()
+ if self._view:
+ self._view.show()
diff --git a/plugins/3MFReader/WorkspaceDialog.qml b/plugins/3MFReader/WorkspaceDialog.qml
new file mode 100644
index 0000000000..cdefd9a4b0
--- /dev/null
+++ b/plugins/3MFReader/WorkspaceDialog.qml
@@ -0,0 +1,172 @@
+// Copyright (c) 2016 Ultimaker B.V.
+// Cura is released under the terms of the AGPLv3 or higher.
+
+import QtQuick 2.1
+import QtQuick.Controls 1.1
+import QtQuick.Layouts 1.1
+import QtQuick.Window 2.1
+
+import UM 1.1 as UM
+
+UM.Dialog
+{
+ title: catalog.i18nc("@title:window", "Import workspace conflict")
+
+ width: 350 * Screen.devicePixelRatio;
+ minimumWidth: 350 * Screen.devicePixelRatio;
+ maximumWidth: 350 * Screen.devicePixelRatio;
+
+ height: 250 * Screen.devicePixelRatio;
+ minimumHeight: 250 * Screen.devicePixelRatio;
+ maximumHeight: 250 * Screen.devicePixelRatio;
+
+ onClosing: manager.notifyClosed()
+ onVisibleChanged:
+ {
+ if(visible)
+ {
+ machineResolveComboBox.currentIndex = 0
+ qualityChangesResolveComboBox.currentIndex = 0
+ materialConflictComboBox.currentIndex = 0
+ }
+ }
+ Item
+ {
+ anchors.fill: parent
+
+ UM.I18nCatalog
+ {
+ id: catalog;
+ name: "cura";
+ }
+
+ ListModel
+ {
+ id: resolveStrategiesModel
+ // Instead of directly adding the list elements, we add them afterwards.
+ // This is because it's impossible to use setting function results to be bound to listElement properties directly.
+ // See http://stackoverflow.com/questions/7659442/listelement-fields-as-properties
+ Component.onCompleted:
+ {
+ append({"key": "override", "label": catalog.i18nc("@action:ComboBox option", "Override existing")});
+ append({"key": "new", "label": catalog.i18nc("@action:ComboBox option", "Create new")});
+ }
+ }
+
+ Column
+ {
+ anchors.fill: parent
+ Label
+ {
+ id: infoLabel
+ width: parent.width
+ text: catalog.i18nc("@action:label", "Cura detected a number of conflicts while importing the workspace. How would you like to resolve these?")
+ wrapMode: Text.Wrap
+ height: 50
+ }
+ UM.TooltipArea
+ {
+ id: machineResolveTooltip
+ width: parent.width
+ height: visible ? 25 : 0
+ text: catalog.i18nc("@info:tooltip", "How should the conflict in the machine be resolved?")
+ visible: manager.machineConflict
+ Row
+ {
+ width: parent.width
+ height: childrenRect.height
+ Label
+ {
+ text: catalog.i18nc("@action:label","Machine")
+ width: 150
+ }
+
+ ComboBox
+ {
+ model: resolveStrategiesModel
+ textRole: "label"
+ id: machineResolveComboBox
+ onActivated:
+ {
+ manager.setResolveStrategy("machine", resolveStrategiesModel.get(index).key)
+ }
+ }
+ }
+ }
+ UM.TooltipArea
+ {
+ id: qualityChangesResolveTooltip
+ width: parent.width
+ height: visible ? 25 : 0
+ text: catalog.i18nc("@info:tooltip", "How should the conflict in the profile be resolved?")
+ visible: manager.qualityChangesConflict
+ Row
+ {
+ width: parent.width
+ height: childrenRect.height
+ Label
+ {
+ text: catalog.i18nc("@action:label","Profile")
+ width: 150
+ }
+
+ ComboBox
+ {
+ model: resolveStrategiesModel
+ textRole: "label"
+ id: qualityChangesResolveComboBox
+ onActivated:
+ {
+ manager.setResolveStrategy("quality_changes", resolveStrategiesModel.get(index).key)
+ }
+ }
+ }
+ }
+ UM.TooltipArea
+ {
+ id: materialResolveTooltip
+ width: parent.width
+ height: visible ? 25 : 0
+ text: catalog.i18nc("@info:tooltip", "How should the conflict in the material(s) be resolved?")
+ visible: manager.materialConflict
+ Row
+ {
+ width: parent.width
+ height: childrenRect.height
+ Label
+ {
+ text: catalog.i18nc("@action:label","Material")
+ width: 150
+ }
+
+ ComboBox
+ {
+ model: resolveStrategiesModel
+ textRole: "label"
+ id: materialResolveComboBox
+ onActivated:
+ {
+ manager.setResolveStrategy("material", resolveStrategiesModel.get(index).key)
+ }
+ }
+ }
+ }
+ }
+ }
+ rightButtons: [
+ Button
+ {
+ id: ok_button
+ text: catalog.i18nc("@action:button","OK");
+ onClicked: { manager.onOkButtonClicked() }
+ enabled: true
+ },
+ Button
+ {
+ id: cancel_button
+ text: catalog.i18nc("@action:button","Cancel");
+ onClicked: { manager.onCancelButtonClicked() }
+ enabled: true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/plugins/3MFReader/__init__.py b/plugins/3MFReader/__init__.py
index 42b1794160..a2af30211d 100644
--- a/plugins/3MFReader/__init__.py
+++ b/plugins/3MFReader/__init__.py
@@ -2,10 +2,11 @@
# Cura is released under the terms of the AGPLv3 or higher.
from . import ThreeMFReader
-
+from . import ThreeMFWorkspaceReader
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
+
def getMetaData():
return {
"plugin": {
@@ -20,8 +21,17 @@ def getMetaData():
"extension": "3mf",
"description": catalog.i18nc("@item:inlistbox", "3MF File")
}
+ ],
+ "workspace_reader":
+ [
+ {
+ "extension": "3mf",
+ "description": catalog.i18nc("@item:inlistbox", "3MF File")
+ }
]
}
+
def register(app):
- return { "mesh_reader": ThreeMFReader.ThreeMFReader() }
+ return {"mesh_reader": ThreeMFReader.ThreeMFReader(),
+ "workspace_reader": ThreeMFWorkspaceReader.ThreeMFWorkspaceReader()}
diff --git a/plugins/3MFWriter/ThreeMFWorkspaceWriter.py b/plugins/3MFWriter/ThreeMFWorkspaceWriter.py
new file mode 100644
index 0000000000..cafc18858f
--- /dev/null
+++ b/plugins/3MFWriter/ThreeMFWorkspaceWriter.py
@@ -0,0 +1,78 @@
+from UM.Workspace.WorkspaceWriter import WorkspaceWriter
+from UM.Application import Application
+from UM.Preferences import Preferences
+from UM.Settings.ContainerRegistry import ContainerRegistry
+from cura.Settings.ExtruderManager import ExtruderManager
+import zipfile
+from io import StringIO
+
+
+class ThreeMFWorkspaceWriter(WorkspaceWriter):
+ def __init__(self):
+ super().__init__()
+
+ def write(self, stream, nodes, mode=WorkspaceWriter.OutputMode.BinaryMode):
+ mesh_writer = Application.getInstance().getMeshFileHandler().getWriter("3MFWriter")
+
+ if not mesh_writer: # We need to have the 3mf mesh writer, otherwise we can't save the entire workspace
+ return False
+
+ # Indicate that the 3mf mesh writer should not close the archive just yet (we still need to add stuff to it).
+ mesh_writer.setStoreArchive(True)
+ mesh_writer.write(stream, nodes, mode)
+
+ archive = mesh_writer.getArchive()
+ if archive is None: # This happens if there was no mesh data to write.
+ archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED)
+
+ global_container_stack = Application.getInstance().getGlobalContainerStack()
+
+ # Add global container stack data to the archive.
+ self._writeContainerToArchive(global_container_stack, archive)
+
+ # Also write all containers in the stack to the file
+ for container in global_container_stack.getContainers():
+ self._writeContainerToArchive(container, archive)
+
+ # Check if the machine has extruders and save all that data as well.
+ for extruder_stack in ExtruderManager.getInstance().getMachineExtruders(global_container_stack.getId()):
+ self._writeContainerToArchive(extruder_stack, archive)
+ for container in extruder_stack.getContainers():
+ self._writeContainerToArchive(container, archive)
+
+ # Write preferences to archive
+ preferences_file = zipfile.ZipInfo("Cura/preferences.cfg")
+ preferences_string = StringIO()
+ Preferences.getInstance().writeToFile(preferences_string)
+ archive.writestr(preferences_file, preferences_string.getvalue())
+
+ # Close the archive & reset states.
+ archive.close()
+ mesh_writer.setStoreArchive(False)
+ return True
+
+ ## Helper function that writes ContainerStacks, InstanceContainers and DefinitionContainers to the archive.
+ # \param container That follows the \type{ContainerInterface} to archive.
+ # \param archive The archive to write to.
+ @staticmethod
+ def _writeContainerToArchive(container, archive):
+ if type(container) == type(ContainerRegistry.getInstance().getEmptyInstanceContainer()):
+ return # Empty file, do nothing.
+
+ file_suffix = ContainerRegistry.getMimeTypeForContainer(type(container)).preferredSuffix
+
+ # Some containers have a base file, which should then be the file to use.
+ if "base_file" in container.getMetaData():
+ base_file = container.getMetaDataEntry("base_file")
+ container = ContainerRegistry.getInstance().findContainers(id = base_file)[0]
+
+ file_name = "Cura/%s.%s" % (container.getId(), file_suffix)
+
+ if file_name in archive.namelist():
+ return # File was already saved, no need to do it again. Uranium guarantees unique ID's, so this should hold.
+
+ file_in_archive = zipfile.ZipInfo(file_name)
+ # For some reason we have to set the compress type of each file as well (it doesn't keep the type of the entire archive)
+ file_in_archive.compress_type = zipfile.ZIP_DEFLATED
+
+ archive.writestr(file_in_archive, container.serialize())
diff --git a/plugins/3MFWriter/ThreeMFWriter.py b/plugins/3MFWriter/ThreeMFWriter.py
new file mode 100644
index 0000000000..d86b119276
--- /dev/null
+++ b/plugins/3MFWriter/ThreeMFWriter.py
@@ -0,0 +1,201 @@
+# Copyright (c) 2015 Ultimaker B.V.
+# Uranium is released under the terms of the AGPLv3 or higher.
+
+from UM.Mesh.MeshWriter import MeshWriter
+from UM.Math.Vector import Vector
+from UM.Logger import Logger
+from UM.Math.Matrix import Matrix
+from UM.Settings.SettingRelation import RelationType
+
+try:
+ import xml.etree.cElementTree as ET
+except ImportError:
+ Logger.log("w", "Unable to load cElementTree, switching to slower version")
+ import xml.etree.ElementTree as ET
+
+import zipfile
+import UM.Application
+
+
+class ThreeMFWriter(MeshWriter):
+ def __init__(self):
+ super().__init__()
+ self._namespaces = {
+ "3mf": "http://schemas.microsoft.com/3dmanufacturing/core/2015/02",
+ "content-types": "http://schemas.openxmlformats.org/package/2006/content-types",
+ "relationships": "http://schemas.openxmlformats.org/package/2006/relationships",
+ "cura": "http://software.ultimaker.com/xml/cura/3mf/2015/10"
+ }
+
+ self._unit_matrix_string = self._convertMatrixToString(Matrix())
+ self._archive = None
+ self._store_archive = False
+
+ def _convertMatrixToString(self, matrix):
+ result = ""
+ result += str(matrix._data[0,0]) + " "
+ result += str(matrix._data[1,0]) + " "
+ result += str(matrix._data[2,0]) + " "
+ result += str(matrix._data[0,1]) + " "
+ result += str(matrix._data[1,1]) + " "
+ result += str(matrix._data[2,1]) + " "
+ result += str(matrix._data[0,2]) + " "
+ result += str(matrix._data[1,2]) + " "
+ result += str(matrix._data[2,2]) + " "
+ result += str(matrix._data[0,3]) + " "
+ result += str(matrix._data[1,3]) + " "
+ result += str(matrix._data[2,3]) + " "
+ return result
+
+ ## Should we store the archive
+ # Note that if this is true, the archive will not be closed.
+ # The object that set this parameter is then responsible for closing it correctly!
+ def setStoreArchive(self, store_archive):
+ self._store_archive = store_archive
+
+ def getArchive(self):
+ return self._archive
+
+ def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode):
+ self._archive = None # Reset archive
+ archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED)
+ try:
+ model_file = zipfile.ZipInfo("3D/3dmodel.model")
+ # Because zipfile is stupid and ignores archive-level compression settings when writing with ZipInfo.
+ model_file.compress_type = zipfile.ZIP_DEFLATED
+
+ # Create content types file
+ content_types_file = zipfile.ZipInfo("[Content_Types].xml")
+ content_types_file.compress_type = zipfile.ZIP_DEFLATED
+ content_types = ET.Element("Types", xmlns = self._namespaces["content-types"])
+ rels_type = ET.SubElement(content_types, "Default", Extension = "rels", ContentType = "application/vnd.openxmlformats-package.relationships+xml")
+ model_type = ET.SubElement(content_types, "Default", Extension = "model", ContentType = "application/vnd.ms-package.3dmanufacturing-3dmodel+xml")
+
+ # Create _rels/.rels file
+ relations_file = zipfile.ZipInfo("_rels/.rels")
+ relations_file.compress_type = zipfile.ZIP_DEFLATED
+ relations_element = ET.Element("Relationships", xmlns = self._namespaces["relationships"])
+ model_relation_element = ET.SubElement(relations_element, "Relationship", Target = "/3D/3dmodel.model", Id = "rel0", Type = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel")
+
+ model = ET.Element("model", unit = "millimeter", xmlns = self._namespaces["3mf"])
+ resources = ET.SubElement(model, "resources")
+ build = ET.SubElement(model, "build")
+
+ added_nodes = []
+ index = 0 # Ensure index always exists (even if there are no nodes to write)
+ # Write all nodes with meshData to the file as objects inside the resource tag
+ for index, n in enumerate(MeshWriter._meshNodes(nodes)):
+ added_nodes.append(n) # Save the nodes that have mesh data
+ object = ET.SubElement(resources, "object", id = str(index+1), type = "model")
+ mesh = ET.SubElement(object, "mesh")
+
+ mesh_data = n.getMeshData()
+ vertices = ET.SubElement(mesh, "vertices")
+ verts = mesh_data.getVertices()
+
+ if verts is None:
+ Logger.log("d", "3mf writer can't write nodes without mesh data. Skipping this node.")
+ continue # No mesh data, nothing to do.
+ if mesh_data.hasIndices():
+ for face in mesh_data.getIndices():
+ v1 = verts[face[0]]
+ v2 = verts[face[1]]
+ v3 = verts[face[2]]
+ xml_vertex1 = ET.SubElement(vertices, "vertex", x = str(v1[0]), y = str(v1[1]), z = str(v1[2]))
+ xml_vertex2 = ET.SubElement(vertices, "vertex", x = str(v2[0]), y = str(v2[1]), z = str(v2[2]))
+ xml_vertex3 = ET.SubElement(vertices, "vertex", x = str(v3[0]), y = str(v3[1]), z = str(v3[2]))
+
+ triangles = ET.SubElement(mesh, "triangles")
+ for face in mesh_data.getIndices():
+ triangle = ET.SubElement(triangles, "triangle", v1 = str(face[0]) , v2 = str(face[1]), v3 = str(face[2]))
+ else:
+ triangles = ET.SubElement(mesh, "triangles")
+ for idx, vert in enumerate(verts):
+ xml_vertex = ET.SubElement(vertices, "vertex", x = str(vert[0]), y = str(vert[1]), z = str(vert[2]))
+
+ # If we have no faces defined, assume that every three subsequent vertices form a face.
+ if idx % 3 == 0:
+ triangle = ET.SubElement(triangles, "triangle", v1 = str(idx), v2 = str(idx + 1), v3 = str(idx + 2))
+
+ # Handle per object settings
+ stack = n.callDecoration("getStack")
+ if stack is not None:
+ changed_setting_keys = set(stack.getTop().getAllKeys())
+
+ # Ensure that we save the extruder used for this object.
+ if stack.getProperty("machine_extruder_count", "value") > 1:
+ changed_setting_keys.add("extruder_nr")
+
+ settings_xml = ET.SubElement(object, "settings", xmlns=self._namespaces["cura"])
+
+ # Get values for all changed settings & save them.
+ for key in changed_setting_keys:
+ setting_xml = ET.SubElement(settings_xml, "setting", key = key)
+ setting_xml.text = str(stack.getProperty(key, "value"))
+
+ # Add one to the index as we haven't incremented the last iteration.
+ index += 1
+ nodes_to_add = set()
+
+ for node in added_nodes:
+ # Check the parents of the nodes with mesh_data and ensure that they are also added.
+ parent_node = node.getParent()
+ while parent_node is not None:
+ if parent_node.callDecoration("isGroup"):
+ nodes_to_add.add(parent_node)
+ parent_node = parent_node.getParent()
+ else:
+ parent_node = None
+
+ # Sort all the nodes by depth (so nodes with the highest depth are done first)
+ sorted_nodes_to_add = sorted(nodes_to_add, key=lambda node: node.getDepth(), reverse = True)
+
+ # We have already saved the nodes with mesh data, but now we also want to save nodes required for the scene
+ for node in sorted_nodes_to_add:
+ object = ET.SubElement(resources, "object", id=str(index + 1), type="model")
+ components = ET.SubElement(object, "components")
+ for child in node.getChildren():
+ if child in added_nodes:
+ component = ET.SubElement(components, "component", objectid = str(added_nodes.index(child) + 1), transform = self._convertMatrixToString(child.getLocalTransformation()))
+ index += 1
+ added_nodes.append(node)
+
+ # Create a transformation Matrix to convert from our worldspace into 3MF.
+ # First step: flip the y and z axis.
+ transformation_matrix = Matrix()
+ transformation_matrix._data[1, 1] = 0
+ transformation_matrix._data[1, 2] = -1
+ transformation_matrix._data[2, 1] = 1
+ transformation_matrix._data[2, 2] = 0
+
+ global_container_stack = UM.Application.getInstance().getGlobalContainerStack()
+ # Second step: 3MF defines the left corner of the machine as center, whereas cura uses the center of the
+ # build volume.
+ if global_container_stack:
+ translation_vector = Vector(x=global_container_stack.getProperty("machine_width", "value") / 2,
+ y=global_container_stack.getProperty("machine_depth", "value") / 2,
+ z=0)
+ translation_matrix = Matrix()
+ translation_matrix.setByTranslation(translation_vector)
+ transformation_matrix.preMultiply(translation_matrix)
+
+ # Find out what the final build items are and add them.
+ for node in added_nodes:
+ if node.getParent().callDecoration("isGroup") is None:
+ node_matrix = node.getLocalTransformation()
+
+ ET.SubElement(build, "item", objectid = str(added_nodes.index(node) + 1), transform = self._convertMatrixToString(node_matrix.preMultiply(transformation_matrix)))
+
+ archive.writestr(model_file, b' \n' + ET.tostring(model))
+ archive.writestr(content_types_file, b' \n' + ET.tostring(content_types))
+ archive.writestr(relations_file, b' \n' + ET.tostring(relations_element))
+ except Exception as e:
+ Logger.logException("e", "Error writing zip file")
+ return False
+ finally:
+ if not self._store_archive:
+ archive.close()
+ else:
+ self._archive = archive
+
+ return True
diff --git a/plugins/3MFWriter/__init__.py b/plugins/3MFWriter/__init__.py
new file mode 100644
index 0000000000..1dbc0bf281
--- /dev/null
+++ b/plugins/3MFWriter/__init__.py
@@ -0,0 +1,38 @@
+# Copyright (c) 2015 Ultimaker B.V.
+# Uranium is released under the terms of the AGPLv3 or higher.
+
+from UM.i18n import i18nCatalog
+from . import ThreeMFWorkspaceWriter
+from . import ThreeMFWriter
+
+i18n_catalog = i18nCatalog("uranium")
+
+def getMetaData():
+ return {
+ "plugin": {
+ "name": i18n_catalog.i18nc("@label", "3MF Writer"),
+ "author": "Ultimaker",
+ "version": "1.0",
+ "description": i18n_catalog.i18nc("@info:whatsthis", "Provides support for writing 3MF files."),
+ "api": 3
+ },
+ "mesh_writer": {
+ "output": [{
+ "extension": "3mf",
+ "description": i18n_catalog.i18nc("@item:inlistbox", "3MF file"),
+ "mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+ "mode": ThreeMFWriter.ThreeMFWriter.OutputMode.BinaryMode
+ }]
+ },
+ "workspace_writer": {
+ "output": [{
+ "extension": "3mf",
+ "description": i18n_catalog.i18nc("@item:inlistbox", "3MF file"),
+ "mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+ "mode": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter.OutputMode.BinaryMode
+ }]
+ }
+ }
+
+def register(app):
+ return {"mesh_writer": ThreeMFWriter.ThreeMFWriter(), "workspace_writer": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter()}
diff --git a/plugins/CuraEngineBackend/CuraEngineBackend.py b/plugins/CuraEngineBackend/CuraEngineBackend.py
index 89b3ed7a82..9d448e11bb 100644
--- a/plugins/CuraEngineBackend/CuraEngineBackend.py
+++ b/plugins/CuraEngineBackend/CuraEngineBackend.py
@@ -220,6 +220,9 @@ class CuraEngineBackend(Backend):
#
# \param job The start slice job that was just finished.
def _onStartSliceCompleted(self, job):
+ if self._error_message:
+ self._error_message.hide()
+
# Note that cancelled slice jobs can still call this method.
if self._start_slice_job is job:
self._start_slice_job = None
@@ -243,9 +246,8 @@ class CuraEngineBackend(Backend):
error_keys = []
for extruder in extruders:
error_keys.extend(extruder.getErrorKeys())
- else:
+ if not extruders:
error_keys = self._global_container_stack.getErrorKeys()
-
error_labels = set()
definition_container = self._global_container_stack.getBottom()
for key in error_keys:
@@ -259,6 +261,14 @@ class CuraEngineBackend(Backend):
self.backendStateChange.emit(BackendState.NotStarted)
return
+ if job.getResult() == StartSliceJob.StartJobResult.BuildPlateError:
+ if Application.getInstance().getPlatformActivity:
+ self._error_message = Message(catalog.i18nc("@info:status", "Unable to slice because the prime tower or prime position(s) are invalid."))
+ self._error_message.show()
+ self.backendStateChange.emit(BackendState.Error)
+ else:
+ self.backendStateChange.emit(BackendState.NotStarted)
+
if job.getResult() == StartSliceJob.StartJobResult.NothingToSlice:
if Application.getInstance().getPlatformActivity:
self._error_message = Message(catalog.i18nc("@info:status", "Nothing to slice because none of the models fit the build volume. Please scale or rotate models to fit."))
diff --git a/plugins/CuraEngineBackend/StartSliceJob.py b/plugins/CuraEngineBackend/StartSliceJob.py
index b343c307af..0319186518 100644
--- a/plugins/CuraEngineBackend/StartSliceJob.py
+++ b/plugins/CuraEngineBackend/StartSliceJob.py
@@ -25,6 +25,7 @@ class StartJobResult(IntEnum):
SettingError = 3
NothingToSlice = 4
MaterialIncompatible = 5
+ BuildPlateError = 6
## Formatter class that handles token expansion in start/end gcod
@@ -80,7 +81,7 @@ class StartSliceJob(Job):
return
if Application.getInstance().getBuildVolume().hasErrors():
- self.setResult(StartJobResult.SettingError)
+ self.setResult(StartJobResult.BuildPlateError)
return
for extruder_stack in cura.Settings.ExtruderManager.getInstance().getMachineExtruders(stack.getId()):
diff --git a/plugins/CuraProfileReader/CuraProfileReader.py b/plugins/CuraProfileReader/CuraProfileReader.py
index d1be5b59a8..2198d73b22 100644
--- a/plugins/CuraProfileReader/CuraProfileReader.py
+++ b/plugins/CuraProfileReader/CuraProfileReader.py
@@ -99,4 +99,6 @@ class CuraProfileReader(ProfileReader):
return []
filenames, outputs = profile_convert_funcs[0](serialized, profile_id)
+ if filenames is None and outputs is None:
+ return []
return list(zip(outputs, filenames))
diff --git a/plugins/GCodeProfileReader/GCodeProfileReader.py b/plugins/GCodeProfileReader/GCodeProfileReader.py
index 24c92d08e9..abfef6e296 100644
--- a/plugins/GCodeProfileReader/GCodeProfileReader.py
+++ b/plugins/GCodeProfileReader/GCodeProfileReader.py
@@ -70,10 +70,19 @@ class GCodeProfileReader(ProfileReader):
json_data = json.loads(serialized)
- profile_strings = [json_data["global_quality"]]
- profile_strings.extend(json_data.get("extruder_quality", []))
+ profiles = []
+ global_profile = readQualityProfileFromString(json_data["global_quality"])
- return [readQualityProfileFromString(profile_string) for profile_string in profile_strings]
+ # This is a fix for profiles created with 2.3.0 For some reason it added the "extruder" property to the
+ # global profile.
+ # The fix is simple and safe, as a global profile should never have the extruder entry.
+ if global_profile.getMetaDataEntry("extruder", None) is not None:
+ global_profile.setMetaDataEntry("extruder", None)
+ profiles.append(global_profile)
+
+ for profile_string in json_data.get("extruder_quality", []):
+ profiles.append(readQualityProfileFromString(profile_string))
+ return profiles
## Unescape a string which has been escaped for use in a gcode comment.
#
diff --git a/plugins/MachineSettingsAction/MachineSettingsAction.py b/plugins/MachineSettingsAction/MachineSettingsAction.py
index a37aa9c5cb..c3a7f50952 100644
--- a/plugins/MachineSettingsAction/MachineSettingsAction.py
+++ b/plugins/MachineSettingsAction/MachineSettingsAction.py
@@ -1,14 +1,14 @@
# Copyright (c) 2016 Ultimaker B.V.
# Cura is released under the terms of the AGPLv3 or higher.
-from PyQt5.QtCore import pyqtSlot
+from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot
from cura.MachineAction import MachineAction
-import cura.Settings.CuraContainerRegistry
import UM.Application
import UM.Settings.InstanceContainer
import UM.Settings.DefinitionContainer
+import UM.Settings.ContainerRegistry
import UM.Logger
import UM.i18n
@@ -19,23 +19,44 @@ class MachineSettingsAction(MachineAction):
super().__init__("MachineSettingsAction", catalog.i18nc("@action", "Machine Settings"))
self._qml_url = "MachineSettingsAction.qml"
- cura.Settings.CuraContainerRegistry.getInstance().containerAdded.connect(self._onContainerAdded)
+ self._container_index = 0
+
+ self._container_registry = UM.Settings.ContainerRegistry.getInstance()
+ self._container_registry.containerAdded.connect(self._onContainerAdded)
def _reset(self):
global_container_stack = UM.Application.getInstance().getGlobalContainerStack()
- if global_container_stack:
- variant = global_container_stack.findContainer({"type": "variant"})
- if variant and variant.getId() == "empty_variant":
- variant_index = global_container_stack.getContainerIndex(variant)
- self._createVariant(global_container_stack, variant_index)
+ if not global_container_stack:
+ return
- def _createVariant(self, global_container_stack, variant_index):
- # Create and switch to a variant to store the settings in
- new_variant = UM.Settings.InstanceContainer(global_container_stack.getName() + "_variant")
- new_variant.addMetaDataEntry("type", "variant")
- new_variant.setDefinition(global_container_stack.getBottom())
- UM.Settings.ContainerRegistry.getInstance().addContainer(new_variant)
- global_container_stack.replaceContainer(variant_index, new_variant)
+ # Make sure there is a definition_changes container to store the machine settings
+ definition_changes_container = global_container_stack.findContainer({"type": "definition_changes"})
+ if not definition_changes_container:
+ definition_changes_container = self._createDefinitionChangesContainer(global_container_stack)
+
+ # Notify the UI in which container to store the machine settings data
+ container_index = global_container_stack.getContainerIndex(definition_changes_container)
+ if container_index != self._container_index:
+ self._container_index = container_index
+ self.containerIndexChanged.emit()
+
+ def _createDefinitionChangesContainer(self, global_container_stack, container_index = None):
+ definition_changes_container = UM.Settings.InstanceContainer(global_container_stack.getName() + "_settings")
+ definition = global_container_stack.getBottom()
+ definition_changes_container.setDefinition(definition)
+ definition_changes_container.addMetaDataEntry("type", "definition_changes")
+
+ self._container_registry.addContainer(definition_changes_container)
+ # Insert definition_changes between the definition and the variant
+ global_container_stack.insertContainer(-1, definition_changes_container)
+
+ return definition_changes_container
+
+ containerIndexChanged = pyqtSignal()
+
+ @pyqtProperty(int, notify = containerIndexChanged)
+ def containerIndex(self):
+ return self._container_index
def _onContainerAdded(self, container):
# Add this action as a supported action to all machine definitions
@@ -44,10 +65,6 @@ class MachineSettingsAction(MachineAction):
# Multiextruder printers are not currently supported
UM.Logger.log("d", "Not attaching MachineSettingsAction to %s; Multi-extrusion printers are not supported", container.getId())
return
- if container.getMetaDataEntry("has_variants", False):
- # Machines that use variants are not currently supported
- UM.Logger.log("d", "Not attaching MachineSettingsAction to %s; Machines that use variants are not supported", container.getId())
- return
UM.Application.getInstance().getMachineActionManager().addSupportedAction(container.getId(), self.getKey())
@@ -78,7 +95,7 @@ class MachineSettingsAction(MachineAction):
# Set the material container to a sane default
if material_container.getId() == "empty_material":
search_criteria = { "type": "material", "definition": "fdmprinter", "id": "*pla*" }
- containers = UM.Settings.ContainerRegistry.getInstance().findInstanceContainers(**search_criteria)
+ containers = self._container_registry.findInstanceContainers(**search_criteria)
if containers:
global_container_stack.replaceContainer(material_index, containers[0])
else:
@@ -87,7 +104,7 @@ class MachineSettingsAction(MachineAction):
if "has_materials" in global_container_stack.getMetaData():
global_container_stack.removeMetaDataEntry("has_materials")
- empty_material = UM.Settings.ContainerRegistry.getInstance().findInstanceContainers(id = "empty_material")[0]
+ empty_material = self._container_registry.findInstanceContainers(id = "empty_material")[0]
global_container_stack.replaceContainer(material_index, empty_material)
UM.Application.getInstance().globalContainerStackChanged.emit()
\ No newline at end of file
diff --git a/plugins/MachineSettingsAction/MachineSettingsAction.qml b/plugins/MachineSettingsAction/MachineSettingsAction.qml
index a1d9bcfafd..ea299438f0 100644
--- a/plugins/MachineSettingsAction/MachineSettingsAction.qml
+++ b/plugins/MachineSettingsAction/MachineSettingsAction.qml
@@ -147,19 +147,40 @@ Cura.MachineAction
ComboBox
{
- model: ["RepRap (Marlin/Sprinter)", "UltiGCode", "Repetier"]
+ model: ListModel
+ {
+ id: flavorModel
+ Component.onCompleted:
+ {
+ // Options come in as a string-representation of an OrderedDict
+ var options = machineGCodeFlavorProvider.properties.options.match(/^OrderedDict\(\[\((.*)\)\]\)$/);
+ if(options)
+ {
+ options = options[1].split("), (")
+ for(var i = 0; i < options.length; i++)
+ {
+ var option = options[i].substring(1, options[i].length - 1).split("', '")
+ flavorModel.append({text: option[1], value: option[0]});
+ }
+ }
+ }
+ }
currentIndex:
{
- var index = model.indexOf(machineGCodeFlavorProvider.properties.value);
- if(index == -1)
+ var currentValue = machineGCodeFlavorProvider.properties.value;
+ var index = 0;
+ for(var i = 0; i < flavorModel.count; i++)
{
- index = 0;
+ if(flavorModel.get(i).value == currentValue) {
+ index = i;
+ break;
+ }
}
return index
}
onActivated:
{
- machineGCodeFlavorProvider.setPropertyValue("value", model[index]);
+ machineGCodeFlavorProvider.setPropertyValue("value", flavorModel.get(index).value);
manager.updateHasMaterialsMetadata();
}
}
@@ -273,17 +294,20 @@ Cura.MachineAction
Label
{
text: catalog.i18nc("@label", "Nozzle size")
+ visible: !Cura.MachineManager.hasVariants
}
TextField
{
id: nozzleSizeField
text: machineNozzleSizeProvider.properties.value
+ visible: !Cura.MachineManager.hasVariants
validator: RegExpValidator { regExp: /[0-9\.]{0,6}/ }
onEditingFinished: { machineNozzleSizeProvider.setPropertyValue("value", text) }
}
Label
{
text: catalog.i18nc("@label", "mm")
+ visible: !Cura.MachineManager.hasVariants
}
}
}
@@ -308,6 +332,8 @@ Cura.MachineAction
id: machineStartGcodeField
width: parent.width
height: parent.height - y
+ font: UM.Theme.getFont("fixed")
+ wrapMode: TextEdit.NoWrap
text: machineStartGcodeProvider.properties.value
onActiveFocusChanged:
{
@@ -330,6 +356,8 @@ Cura.MachineAction
id: machineEndGcodeField
width: parent.width
height: parent.height - y
+ font: UM.Theme.getFont("fixed")
+ wrapMode: TextEdit.NoWrap
text: machineEndGcodeProvider.properties.value
onActiveFocusChanged:
{
@@ -377,7 +405,7 @@ Cura.MachineAction
containerStackId: Cura.MachineManager.activeMachineId
key: "machine_width"
watchedProperties: [ "value" ]
- storeIndex: 4
+ storeIndex: manager.containerIndex
}
UM.SettingPropertyProvider
@@ -387,7 +415,7 @@ Cura.MachineAction
containerStackId: Cura.MachineManager.activeMachineId
key: "machine_depth"
watchedProperties: [ "value" ]
- storeIndex: 4
+ storeIndex: manager.containerIndex
}
UM.SettingPropertyProvider
@@ -397,7 +425,7 @@ Cura.MachineAction
containerStackId: Cura.MachineManager.activeMachineId
key: "machine_height"
watchedProperties: [ "value" ]
- storeIndex: 4
+ storeIndex: manager.containerIndex
}
UM.SettingPropertyProvider
@@ -407,7 +435,7 @@ Cura.MachineAction
containerStackId: Cura.MachineManager.activeMachineId
key: "machine_heated_bed"
watchedProperties: [ "value" ]
- storeIndex: 4
+ storeIndex: manager.containerIndex
}
UM.SettingPropertyProvider
@@ -417,7 +445,7 @@ Cura.MachineAction
containerStackId: Cura.MachineManager.activeMachineId
key: "machine_center_is_zero"
watchedProperties: [ "value" ]
- storeIndex: 4
+ storeIndex: manager.containerIndex
}
UM.SettingPropertyProvider
@@ -426,8 +454,8 @@ Cura.MachineAction
containerStackId: Cura.MachineManager.activeMachineId
key: "machine_gcode_flavor"
- watchedProperties: [ "value" ]
- storeIndex: 4
+ watchedProperties: [ "value", "options" ]
+ storeIndex: manager.containerIndex
}
UM.SettingPropertyProvider
@@ -437,7 +465,7 @@ Cura.MachineAction
containerStackId: Cura.MachineManager.activeMachineId
key: "machine_nozzle_size"
watchedProperties: [ "value" ]
- storeIndex: 4
+ storeIndex: manager.containerIndex
}
UM.SettingPropertyProvider
@@ -447,7 +475,7 @@ Cura.MachineAction
containerStackId: Cura.MachineManager.activeMachineId
key: "gantry_height"
watchedProperties: [ "value" ]
- storeIndex: 4
+ storeIndex: manager.containerIndex
}
UM.SettingPropertyProvider
@@ -457,7 +485,7 @@ Cura.MachineAction
containerStackId: Cura.MachineManager.activeMachineId
key: "machine_head_with_fans_polygon"
watchedProperties: [ "value" ]
- storeIndex: 4
+ storeIndex: manager.containerIndex
}
@@ -468,7 +496,7 @@ Cura.MachineAction
containerStackId: Cura.MachineManager.activeMachineId
key: "machine_start_gcode"
watchedProperties: [ "value" ]
- storeIndex: 4
+ storeIndex: manager.containerIndex
}
UM.SettingPropertyProvider
@@ -478,7 +506,7 @@ Cura.MachineAction
containerStackId: Cura.MachineManager.activeMachineId
key: "machine_end_gcode"
watchedProperties: [ "value" ]
- storeIndex: 4
+ storeIndex: manager.containerIndex
}
}
\ No newline at end of file
diff --git a/plugins/RemovableDriveOutputDevice/RemovableDriveOutputDevice.py b/plugins/RemovableDriveOutputDevice/RemovableDriveOutputDevice.py
index 3fdd6b3e3e..b6505e7e6b 100644
--- a/plugins/RemovableDriveOutputDevice/RemovableDriveOutputDevice.py
+++ b/plugins/RemovableDriveOutputDevice/RemovableDriveOutputDevice.py
@@ -6,7 +6,7 @@ import os.path
from UM.Application import Application
from UM.Logger import Logger
from UM.Message import Message
-from UM.Mesh.WriteMeshJob import WriteMeshJob
+from UM.FileHandler.WriteFileJob import WriteFileJob
from UM.Mesh.MeshWriter import MeshWriter
from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator
from UM.OutputDevice.OutputDevice import OutputDevice
@@ -37,13 +37,17 @@ class RemovableDriveOutputDevice(OutputDevice):
# meshes.
# \param limit_mimetypes Should we limit the available MIME types to the
# MIME types available to the currently active machine?
- def requestWrite(self, nodes, file_name = None, filter_by_machine = False):
+ def requestWrite(self, nodes, file_name = None, filter_by_machine = False, file_handler = None):
filter_by_machine = True # This plugin is indended to be used by machine (regardless of what it was told to do)
if self._writing:
raise OutputDeviceError.DeviceBusyError()
# Formats supported by this application (File types that we can actually write)
- file_formats = Application.getInstance().getMeshFileHandler().getSupportedFileTypesWrite()
+ if file_handler:
+ file_formats = file_handler.getSupportedFileTypesWrite()
+ else:
+ file_formats = Application.getInstance().getMeshFileHandler().getSupportedFileTypesWrite()
+
if filter_by_machine:
container = Application.getInstance().getGlobalContainerStack().findContainer({"file_formats": "*"})
@@ -58,7 +62,11 @@ class RemovableDriveOutputDevice(OutputDevice):
raise OutputDeviceError.WriteRequestFailedError()
# Just take the first file format available.
- writer = Application.getInstance().getMeshFileHandler().getWriterByMimeType(file_formats[0]["mime_type"])
+ if file_handler is not None:
+ writer = file_handler.getWriterByMimeType(file_formats[0]["mime_type"])
+ else:
+ writer = Application.getInstance().getMeshFileHandler().getWriterByMimeType(file_formats[0]["mime_type"])
+
extension = file_formats[0]["extension"]
if file_name is None:
@@ -72,7 +80,7 @@ class RemovableDriveOutputDevice(OutputDevice):
Logger.log("d", "Writing to %s", file_name)
# Using buffering greatly reduces the write time for many lines of gcode
self._stream = open(file_name, "wt", buffering = 1, encoding = "utf-8")
- job = WriteMeshJob(writer, self._stream, nodes, MeshWriter.OutputMode.TextMode)
+ job = WriteFileJob(writer, self._stream, nodes, MeshWriter.OutputMode.TextMode)
job.setFileName(file_name)
job.progress.connect(self._onProgress)
job.finished.connect(self._onFinished)
diff --git a/plugins/UM3NetworkPrinting/DiscoverUM3Action.py b/plugins/UM3NetworkPrinting/DiscoverUM3Action.py
new file mode 100644
index 0000000000..c4ffdb8472
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/DiscoverUM3Action.py
@@ -0,0 +1,148 @@
+from cura.MachineAction import MachineAction
+
+from UM.Application import Application
+from UM.PluginRegistry import PluginRegistry
+from UM.Logger import Logger
+
+from PyQt5.QtCore import pyqtSignal, pyqtProperty, pyqtSlot, QUrl, QObject
+from PyQt5.QtQml import QQmlComponent, QQmlContext
+
+import os.path
+
+import time
+
+from UM.i18n import i18nCatalog
+catalog = i18nCatalog("cura")
+
+class DiscoverUM3Action(MachineAction):
+ def __init__(self):
+ super().__init__("DiscoverUM3Action", catalog.i18nc("@action","Connect via Network"))
+ self._qml_url = "DiscoverUM3Action.qml"
+
+ self._network_plugin = None
+
+ self.__additional_components_context = None
+ self.__additional_component = None
+ self.__additional_components_view = None
+
+ Application.getInstance().engineCreatedSignal.connect(self._createAdditionalComponentsView)
+
+ self._last_zeroconf_event_time = time.time()
+ self._zeroconf_change_grace_period = 0.25 # Time to wait after a zeroconf service change before allowing a zeroconf reset
+
+ printersChanged = pyqtSignal()
+
+ @pyqtSlot()
+ def startDiscovery(self):
+ if not self._network_plugin:
+ self._network_plugin = Application.getInstance().getOutputDeviceManager().getOutputDevicePlugin("UM3NetworkPrinting")
+ self._network_plugin.printerListChanged.connect(self._onPrinterDiscoveryChanged)
+ self.printersChanged.emit()
+
+ ## Re-filters the list of printers.
+ @pyqtSlot()
+ def reset(self):
+ self.printersChanged.emit()
+
+ @pyqtSlot()
+ def restartDiscovery(self):
+ # Ensure that there is a bit of time after a printer has been discovered.
+ # This is a work around for an issue with Qt 5.5.1 up to Qt 5.7 which can segfault if we do this too often.
+ # It's most likely that the QML engine is still creating delegates, where the python side already deleted or
+ # garbage collected the data.
+ # Whatever the case, waiting a bit ensures that it doesn't crash.
+ if time.time() - self._last_zeroconf_event_time > self._zeroconf_change_grace_period:
+ if not self._network_plugin:
+ self.startDiscovery()
+ else:
+ self._network_plugin.startDiscovery()
+
+ @pyqtSlot(str, str)
+ def removeManualPrinter(self, key, address):
+ if not self._network_plugin:
+ return
+
+ self._network_plugin.removeManualPrinter(key, address)
+
+ @pyqtSlot(str, str)
+ def setManualPrinter(self, key, address):
+ if key != "":
+ # This manual printer replaces a current manual printer
+ self._network_plugin.removeManualPrinter(key)
+
+ if address != "":
+ self._network_plugin.addManualPrinter(address)
+
+ def _onPrinterDiscoveryChanged(self, *args):
+ self._last_zeroconf_event_time = time.time()
+ self.printersChanged.emit()
+
+ @pyqtProperty("QVariantList", notify = printersChanged)
+ def foundDevices(self):
+ if self._network_plugin:
+ if Application.getInstance().getGlobalContainerStack():
+ global_printer_type = Application.getInstance().getGlobalContainerStack().getBottom().getId()
+ else:
+ global_printer_type = "unknown"
+
+ printers = list(self._network_plugin.getPrinters().values())
+ # TODO; There are still some testing printers that don't have a correct printer type, so don't filter out unkown ones just yet.
+ printers = [printer for printer in printers if printer.printerType == global_printer_type or printer.printerType == "unknown"]
+ printers.sort(key = lambda k: k.name)
+ return printers
+ else:
+ return []
+
+ @pyqtSlot(str)
+ def setKey(self, key):
+ global_container_stack = Application.getInstance().getGlobalContainerStack()
+ if global_container_stack:
+ meta_data = global_container_stack.getMetaData()
+ if "um_network_key" in meta_data:
+ global_container_stack.setMetaDataEntry("um_network_key", key)
+ # Delete old authentication data.
+ global_container_stack.removeMetaDataEntry("network_authentication_id")
+ global_container_stack.removeMetaDataEntry("network_authentication_key")
+ else:
+ global_container_stack.addMetaDataEntry("um_network_key", key)
+
+ if self._network_plugin:
+ # Ensure that the connection states are refreshed.
+ self._network_plugin.reCheckConnections()
+
+ @pyqtSlot(result = str)
+ def getStoredKey(self):
+ global_container_stack = Application.getInstance().getGlobalContainerStack()
+ if global_container_stack:
+ meta_data = global_container_stack.getMetaData()
+ if "um_network_key" in meta_data:
+ return global_container_stack.getMetaDataEntry("um_network_key")
+
+ return ""
+
+ @pyqtSlot()
+ def loadConfigurationFromPrinter(self):
+ machine_manager = Application.getInstance().getMachineManager()
+ hotend_ids = machine_manager.printerOutputDevices[0].hotendIds
+ for index in range(len(hotend_ids)):
+ machine_manager.printerOutputDevices[0].hotendIdChanged.emit(index, hotend_ids[index])
+ material_ids = machine_manager.printerOutputDevices[0].materialIds
+ for index in range(len(material_ids)):
+ machine_manager.printerOutputDevices[0].materialIdChanged.emit(index, material_ids[index])
+
+ def _createAdditionalComponentsView(self):
+ Logger.log("d", "Creating additional ui components for UM3.")
+ path = QUrl.fromLocalFile(os.path.join(PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting"), "UM3InfoComponents.qml"))
+ self.__additional_component = QQmlComponent(Application.getInstance()._engine, path)
+
+ # We need access to engine (although technically we can't)
+ self.__additional_components_context = QQmlContext(Application.getInstance()._engine.rootContext())
+ self.__additional_components_context.setContextProperty("manager", self)
+
+ self.__additional_components_view = self.__additional_component.create(self.__additional_components_context)
+ if not self.__additional_components_view:
+ Logger.log("w", "Could not create ui components for UM3.")
+ return
+
+ Application.getInstance().addAdditionalComponent("monitorButtons", self.__additional_components_view.findChild(QObject, "networkPrinterConnectButton"))
+ Application.getInstance().addAdditionalComponent("machinesDetailPane", self.__additional_components_view.findChild(QObject, "networkPrinterConnectionInfo"))
diff --git a/plugins/UM3NetworkPrinting/DiscoverUM3Action.qml b/plugins/UM3NetworkPrinting/DiscoverUM3Action.qml
new file mode 100644
index 0000000000..a80ed1d179
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/DiscoverUM3Action.qml
@@ -0,0 +1,369 @@
+import UM 1.2 as UM
+import Cura 1.0 as Cura
+
+import QtQuick 2.2
+import QtQuick.Controls 1.1
+import QtQuick.Layouts 1.1
+import QtQuick.Window 2.1
+
+Cura.MachineAction
+{
+ id: base
+ anchors.fill: parent;
+ property var selectedPrinter: null
+ property bool completeProperties: true
+ property var connectingToPrinter: null
+
+ Connections
+ {
+ target: dialog ? dialog : null
+ ignoreUnknownSignals: true
+ onNextClicked:
+ {
+ // Connect to the printer if the MachineAction is currently shown
+ if(base.parent.wizard == dialog)
+ {
+ connectToPrinter();
+ }
+ }
+ }
+
+ function connectToPrinter()
+ {
+ if(base.selectedPrinter && base.completeProperties)
+ {
+ var printerKey = base.selectedPrinter.getKey()
+ if(connectingToPrinter != printerKey) {
+ // prevent an infinite loop
+ connectingToPrinter = printerKey;
+ manager.setKey(printerKey);
+ completed();
+ }
+ }
+ }
+
+ Column
+ {
+ anchors.fill: parent;
+ id: discoverUM3Action
+ spacing: UM.Theme.getSize("default_margin").height
+
+ SystemPalette { id: palette }
+ UM.I18nCatalog { id: catalog; name:"cura" }
+ Label
+ {
+ id: pageTitle
+ width: parent.width
+ text: catalog.i18nc("@title:window", "Connect to Networked Printer")
+ wrapMode: Text.WordWrap
+ font.pointSize: 18
+ }
+
+ Label
+ {
+ id: pageDescription
+ width: parent.width
+ wrapMode: Text.WordWrap
+ text: catalog.i18nc("@label", "To print directly to your printer over the network, please make sure your printer is connected to the network using a network cable or by connecting your printer to your WIFI network. If you don't connect Cura with your printer, you can still use a USB drive to transfer g-code files to your printer.\n\nSelect your printer from the list below:")
+ }
+
+ Row
+ {
+ spacing: UM.Theme.getSize("default_lining").width
+
+ Button
+ {
+ id: addButton
+ text: catalog.i18nc("@action:button", "Add");
+ onClicked:
+ {
+ manualPrinterDialog.showDialog("", "");
+ }
+ }
+
+ Button
+ {
+ id: editButton
+ text: catalog.i18nc("@action:button", "Edit")
+ enabled: base.selectedPrinter != null && base.selectedPrinter.getProperty("manual") == "true"
+ onClicked:
+ {
+ manualPrinterDialog.showDialog(base.selectedPrinter.getKey(), base.selectedPrinter.ipAddress);
+ }
+ }
+
+ Button
+ {
+ id: removeButton
+ text: catalog.i18nc("@action:button", "Remove")
+ enabled: base.selectedPrinter != null && base.selectedPrinter.getProperty("manual") == "true"
+ onClicked: manager.removeManualPrinter(base.selectedPrinter.getKey(), base.selectedPrinter.ipAddress)
+ }
+
+ Button
+ {
+ id: rediscoverButton
+ text: catalog.i18nc("@action:button", "Refresh")
+ onClicked: manager.restartDiscovery()
+ }
+ }
+
+ Row
+ {
+ id: contentRow
+ width: parent.width
+ spacing: UM.Theme.getSize("default_margin").width
+
+ Column
+ {
+ width: parent.width * 0.5
+ spacing: UM.Theme.getSize("default_margin").height
+
+ ScrollView
+ {
+ id: objectListContainer
+ frameVisible: true
+ width: parent.width
+ height: base.height - contentRow.y - discoveryTip.height
+
+ Rectangle
+ {
+ parent: viewport
+ anchors.fill: parent
+ color: palette.light
+ }
+
+ ListView
+ {
+ id: listview
+ model: manager.foundDevices
+ onModelChanged:
+ {
+ var selectedKey = manager.getStoredKey();
+ for(var i = 0; i < model.length; i++) {
+ if(model[i].getKey() == selectedKey)
+ {
+ currentIndex = i;
+ return
+ }
+ }
+ currentIndex = -1;
+ }
+ width: parent.width
+ currentIndex: -1
+ onCurrentIndexChanged:
+ {
+ base.selectedPrinter = listview.model[currentIndex];
+ // Only allow connecting if the printer has responded to API query since the last refresh
+ base.completeProperties = base.selectedPrinter != null && base.selectedPrinter.getProperty("incomplete") != "true";
+ }
+ Component.onCompleted: manager.startDiscovery()
+ delegate: Rectangle
+ {
+ height: childrenRect.height
+ color: ListView.isCurrentItem ? palette.highlight : index % 2 ? palette.base : palette.alternateBase
+ width: parent.width
+ Label
+ {
+ anchors.left: parent.left
+ anchors.leftMargin: UM.Theme.getSize("default_margin").width
+ anchors.right: parent.right
+ text: listview.model[index].name
+ color: parent.ListView.isCurrentItem ? palette.highlightedText : palette.text
+ elide: Text.ElideRight
+ }
+
+ MouseArea
+ {
+ anchors.fill: parent;
+ onClicked:
+ {
+ if(!parent.ListView.isCurrentItem)
+ {
+ parent.ListView.view.currentIndex = index;
+ }
+ }
+ }
+ }
+ }
+ }
+ Label
+ {
+ id: discoveryTip
+ anchors.left: parent.left
+ anchors.right: parent.right
+ wrapMode: Text.WordWrap
+ //: Tips label
+ //TODO: get actual link from webteam
+ text: catalog.i18nc("@label", "If your printer is not listed, read the network-printing troubleshooting guide").arg("https://ultimaker.com/en/troubleshooting");
+ onLinkActivated: Qt.openUrlExternally(link)
+ }
+
+ }
+ Column
+ {
+ width: parent.width * 0.5
+ visible: base.selectedPrinter ? true : false
+ spacing: UM.Theme.getSize("default_margin").height
+ Label
+ {
+ width: parent.width
+ wrapMode: Text.WordWrap
+ text: base.selectedPrinter ? base.selectedPrinter.name : ""
+ font: UM.Theme.getFont("large")
+ elide: Text.ElideRight
+ }
+ Grid
+ {
+ visible: base.completeProperties
+ width: parent.width
+ columns: 2
+ Label
+ {
+ width: parent.width * 0.5
+ wrapMode: Text.WordWrap
+ text: catalog.i18nc("@label", "Type")
+ }
+ Label
+ {
+ width: parent.width * 0.5
+ wrapMode: Text.WordWrap
+ text:
+ {
+ if(base.selectedPrinter)
+ {
+ if(base.selectedPrinter.printerType == "ultimaker3")
+ {
+ return catalog.i18nc("@label", "Ultimaker 3")
+ } else if(base.selectedPrinter.printerType == "ultimaker3_extended")
+ {
+ return catalog.i18nc("@label", "Ultimaker 3 Extended")
+ } else
+ {
+ return catalog.i18nc("@label", "Unknown") // We have no idea what type it is. Should not happen 'in the field'
+ }
+ }
+ else
+ {
+ return ""
+ }
+ }
+ }
+ Label
+ {
+ width: parent.width * 0.5
+ wrapMode: Text.WordWrap
+ text: catalog.i18nc("@label", "Firmware version")
+ }
+ Label
+ {
+ width: parent.width * 0.5
+ wrapMode: Text.WordWrap
+ text: base.selectedPrinter ? base.selectedPrinter.firmwareVersion : ""
+ }
+ Label
+ {
+ width: parent.width * 0.5
+ wrapMode: Text.WordWrap
+ text: catalog.i18nc("@label", "Address")
+ }
+ Label
+ {
+ width: parent.width * 0.5
+ wrapMode: Text.WordWrap
+ text: base.selectedPrinter ? base.selectedPrinter.ipAddress : ""
+ }
+ }
+ Label
+ {
+ width: parent.width
+ wrapMode: Text.WordWrap
+ visible: base.selectedPrinter != null && !base.completeProperties
+ text: catalog.i18nc("@label", "The printer at this address has not yet responded." )
+ }
+
+ Button
+ {
+ text: catalog.i18nc("@action:button", "Connect")
+ enabled: (base.selectedPrinter && base.completeProperties) ? true : false
+ onClicked: connectToPrinter()
+ }
+ }
+ }
+ }
+
+ UM.Dialog
+ {
+ id: manualPrinterDialog
+ property string printerKey
+ property alias addressText: addressField.text
+
+ title: catalog.i18nc("@title:window", "Printer Address")
+
+ minimumWidth: 400 * Screen.devicePixelRatio
+ minimumHeight: 120 * Screen.devicePixelRatio
+ width: minimumWidth
+ height: minimumHeight
+
+ signal showDialog(string key, string address)
+ onShowDialog:
+ {
+ printerKey = key;
+
+ addressText = address;
+ addressField.selectAll();
+ addressField.focus = true;
+
+ manualPrinterDialog.show();
+ }
+
+ onAccepted:
+ {
+ manager.setManualPrinter(printerKey, addressText)
+ }
+
+ Column {
+ anchors.fill: parent
+ spacing: UM.Theme.getSize("default_margin").height
+
+ Label
+ {
+ text: catalog.i18nc("@alabel","Enter the IP address or hostname of your printer on the network.")
+ width: parent.width
+ wrapMode: Text.WordWrap
+ }
+
+ TextField
+ {
+ id: addressField
+ width: parent.width
+ maximumLength: 40
+ validator: RegExpValidator
+ {
+ regExp: /[a-zA-Z0-9\.\-\_]*/
+ }
+ }
+ }
+
+ rightButtons: [
+ Button {
+ text: catalog.i18nc("@action:button","Cancel")
+ onClicked:
+ {
+ manualPrinterDialog.reject()
+ manualPrinterDialog.hide()
+ }
+ },
+ Button {
+ text: catalog.i18nc("@action:button", "Ok")
+ onClicked:
+ {
+ manualPrinterDialog.accept()
+ manualPrinterDialog.hide()
+ }
+ enabled: manualPrinterDialog.addressText.trim() != ""
+ isDefault: true
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/plugins/UM3NetworkPrinting/NetworkPrinterOutputDevice.py b/plugins/UM3NetworkPrinting/NetworkPrinterOutputDevice.py
new file mode 100644
index 0000000000..20552a6843
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/NetworkPrinterOutputDevice.py
@@ -0,0 +1,1026 @@
+# Copyright (c) 2016 Ultimaker B.V.
+# Cura is released under the terms of the AGPLv3 or higher.
+
+from UM.i18n import i18nCatalog
+from UM.Application import Application
+from UM.Logger import Logger
+from UM.Signal import signalemitter
+
+from UM.Message import Message
+
+import UM.Settings
+
+from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState
+import cura.Settings.ExtruderManager
+
+from PyQt5.QtNetwork import QHttpMultiPart, QHttpPart, QNetworkRequest, QNetworkAccessManager, QNetworkReply
+from PyQt5.QtCore import QUrl, QTimer, pyqtSignal, pyqtProperty, pyqtSlot, QCoreApplication
+from PyQt5.QtGui import QImage
+from PyQt5.QtWidgets import QMessageBox
+
+import json
+import os
+import gzip
+import zlib
+
+from time import time
+from time import sleep
+
+i18n_catalog = i18nCatalog("cura")
+
+from enum import IntEnum
+
+class AuthState(IntEnum):
+ NotAuthenticated = 1
+ AuthenticationRequested = 2
+ Authenticated = 3
+ AuthenticationDenied = 4
+
+## Network connected (wifi / lan) printer that uses the Ultimaker API
+@signalemitter
+class NetworkPrinterOutputDevice(PrinterOutputDevice):
+ def __init__(self, key, address, properties, api_prefix):
+ super().__init__(key)
+ self._address = address
+ self._key = key
+ self._properties = properties # Properties dict as provided by zero conf
+ self._api_prefix = api_prefix
+
+ self._gcode = None
+ self._print_finished = True # _print_finsihed == False means we're halfway in a print
+
+ self._use_gzip = True # Should we use g-zip compression before sending the data?
+
+ # This holds the full JSON file that was received from the last request.
+ # The JSON looks like:
+ #{
+ # "led": {"saturation": 0.0, "brightness": 100.0, "hue": 0.0},
+ # "beep": {},
+ # "network": {
+ # "wifi_networks": [],
+ # "ethernet": {"connected": true, "enabled": true},
+ # "wifi": {"ssid": "xxxx", "connected": False, "enabled": False}
+ # },
+ # "diagnostics": {},
+ # "bed": {"temperature": {"target": 60.0, "current": 44.4}},
+ # "heads": [{
+ # "max_speed": {"z": 40.0, "y": 300.0, "x": 300.0},
+ # "position": {"z": 20.0, "y": 6.0, "x": 180.0},
+ # "fan": 0.0,
+ # "jerk": {"z": 0.4, "y": 20.0, "x": 20.0},
+ # "extruders": [
+ # {
+ # "feeder": {"max_speed": 45.0, "jerk": 5.0, "acceleration": 3000.0},
+ # "active_material": {"guid": "xxxxxxx", "length_remaining": -1.0},
+ # "hotend": {"temperature": {"target": 0.0, "current": 22.8}, "id": "AA 0.4"}
+ # },
+ # {
+ # "feeder": {"max_speed": 45.0, "jerk": 5.0, "acceleration": 3000.0},
+ # "active_material": {"guid": "xxxx", "length_remaining": -1.0},
+ # "hotend": {"temperature": {"target": 0.0, "current": 22.8}, "id": "BB 0.4"}
+ # }
+ # ],
+ # "acceleration": 3000.0
+ # }],
+ # "status": "printing"
+ #}
+
+ self._json_printer_state = {}
+
+ ## Todo: Hardcoded value now; we should probably read this from the machine file.
+ ## It's okay to leave this for now, as this plugin is um3 only (and has 2 extruders by definition)
+ self._num_extruders = 2
+
+ # These are reinitialised here (from PrinterOutputDevice) to match the new _num_extruders
+ self._hotend_temperatures = [0] * self._num_extruders
+ self._target_hotend_temperatures = [0] * self._num_extruders
+
+ self._material_ids = [""] * self._num_extruders
+ self._hotend_ids = [""] * self._num_extruders
+
+ self.setPriority(2) # Make sure the output device gets selected above local file output
+ self.setName(key)
+ self.setShortDescription(i18n_catalog.i18nc("@action:button Preceded by 'Ready to'.", "Print over network"))
+ self.setDescription(i18n_catalog.i18nc("@properties:tooltip", "Print over network"))
+ self.setIconName("print")
+
+ self._manager = None
+
+ self._post_request = None
+ self._post_reply = None
+ self._post_multi_part = None
+ self._post_part = None
+
+ self._material_multi_part = None
+ self._material_part = None
+
+ self._progress_message = None
+ self._error_message = None
+ self._connection_message = None
+
+ self._update_timer = QTimer()
+ self._update_timer.setInterval(2000) # TODO; Add preference for update interval
+ self._update_timer.setSingleShot(False)
+ self._update_timer.timeout.connect(self._update)
+
+ self._camera_timer = QTimer()
+ self._camera_timer.setInterval(500) # Todo: Add preference for camera update interval
+ self._camera_timer.setSingleShot(False)
+ self._camera_timer.timeout.connect(self._updateCamera)
+
+ self._image_request = None
+ self._image_reply = None
+
+ self._use_stream = True
+ self._stream_buffer = b""
+ self._stream_buffer_start_index = -1
+
+ self._camera_image_id = 0
+
+ self._authentication_counter = 0
+ self._max_authentication_counter = 5 * 60 # Number of attempts before authentication timed out (5 min)
+
+ self._authentication_timer = QTimer()
+ self._authentication_timer.setInterval(1000) # TODO; Add preference for update interval
+ self._authentication_timer.setSingleShot(False)
+ self._authentication_timer.timeout.connect(self._onAuthenticationTimer)
+ self._authentication_request_active = False
+
+ self._authentication_state = AuthState.NotAuthenticated
+ self._authentication_id = None
+ self._authentication_key = None
+
+ self._authentication_requested_message = Message(i18n_catalog.i18nc("@info:status", "Access to the printer requested. Please approve the request on the printer"), lifetime = 0, dismissable = False, progress = 0)
+ self._authentication_failed_message = Message(i18n_catalog.i18nc("@info:status", ""))
+ self._authentication_failed_message.addAction("Retry", i18n_catalog.i18nc("@action:button", "Retry"), None, i18n_catalog.i18nc("@info:tooltip", "Re-send the access request"))
+ self._authentication_failed_message.actionTriggered.connect(self.requestAuthentication)
+ self._authentication_succeeded_message = Message(i18n_catalog.i18nc("@info:status", "Access to the printer accepted"))
+ self._not_authenticated_message = Message(i18n_catalog.i18nc("@info:status", "No access to print with this printer. Unable to send print job."))
+ self._not_authenticated_message.addAction("Request", i18n_catalog.i18nc("@action:button", "Request Access"), None, i18n_catalog.i18nc("@info:tooltip", "Send access request to the printer"))
+ self._not_authenticated_message.actionTriggered.connect(self.requestAuthentication)
+
+ self._camera_image = QImage()
+
+ self._material_post_objects = {}
+ self._connection_state_before_timeout = None
+
+ self._last_response_time = time()
+ self._last_request_time = None
+ self._response_timeout_time = 10
+ self._recreate_network_manager_time = 30 # If we have no connection, re-create network manager every 30 sec.
+ self._recreate_network_manager_count = 1
+
+ self._send_gcode_start = time() # Time when the sending of the g-code started.
+
+ self._last_command = ""
+
+ self._compressing_print = False
+
+ printer_type = self._properties.get(b"machine", b"").decode("utf-8")
+ if printer_type.startswith("9511"):
+ self._updatePrinterType("ultimaker3_extended")
+ elif printer_type.startswith("9066"):
+ self._updatePrinterType("ultimaker3")
+ else:
+ self._updatePrinterType("unknown")
+
+ def _onNetworkAccesibleChanged(self, accessible):
+ Logger.log("d", "Network accessible state changed to: %s", accessible)
+
+ def _onAuthenticationTimer(self):
+ self._authentication_counter += 1
+ self._authentication_requested_message.setProgress(self._authentication_counter / self._max_authentication_counter * 100)
+ if self._authentication_counter > self._max_authentication_counter:
+ self._authentication_timer.stop()
+ Logger.log("i", "Authentication timer ended. Setting authentication to denied")
+ self.setAuthenticationState(AuthState.AuthenticationDenied)
+
+ def _onAuthenticationRequired(self, reply, authenticator):
+ if self._authentication_id is not None and self._authentication_key is not None:
+ Logger.log("d", "Authentication was required. Setting up authenticator.")
+ authenticator.setUser(self._authentication_id)
+ authenticator.setPassword(self._authentication_key)
+ else:
+ Logger.log("d", "No authentication was required. The id is: %s", self._authentication_id)
+
+ def getProperties(self):
+ return self._properties
+
+ @pyqtSlot(str, result = str)
+ def getProperty(self, key):
+ key = key.encode("utf-8")
+ if key in self._properties:
+ return self._properties.get(key, b"").decode("utf-8")
+ else:
+ return ""
+
+ ## Get the unique key of this machine
+ # \return key String containing the key of the machine.
+ @pyqtSlot(result = str)
+ def getKey(self):
+ return self._key
+
+ ## Name of the printer (as returned from the zeroConf properties)
+ @pyqtProperty(str, constant = True)
+ def name(self):
+ return self._properties.get(b"name", b"").decode("utf-8")
+
+ ## Firmware version (as returned from the zeroConf properties)
+ @pyqtProperty(str, constant=True)
+ def firmwareVersion(self):
+ return self._properties.get(b"firmware_version", b"").decode("utf-8")
+
+ ## IPadress of this printer
+ @pyqtProperty(str, constant=True)
+ def ipAddress(self):
+ return self._address
+
+ def _stopCamera(self):
+ self._camera_timer.stop()
+ if self._image_reply:
+ self._image_reply.abort()
+ self._image_reply.downloadProgress.disconnect(self._onStreamDownloadProgress)
+ self._image_reply = None
+ self._image_request = None
+
+ def _startCamera(self):
+ if self._use_stream:
+ self._startCameraStream()
+ else:
+ self._camera_timer.start()
+
+ def _startCameraStream(self):
+ ## Request new image
+ url = QUrl("http://" + self._address + ":8080/?action=stream")
+ self._image_request = QNetworkRequest(url)
+ self._image_reply = self._manager.get(self._image_request)
+ self._image_reply.downloadProgress.connect(self._onStreamDownloadProgress)
+
+ def _updateCamera(self):
+ if not self._manager.networkAccessible():
+ return
+ ## Request new image
+ url = QUrl("http://" + self._address + ":8080/?action=snapshot")
+ image_request = QNetworkRequest(url)
+ self._manager.get(image_request)
+ self._last_request_time = time()
+
+ ## Set the authentication state.
+ # \param auth_state \type{AuthState} Enum value representing the new auth state
+ def setAuthenticationState(self, auth_state):
+ if auth_state == AuthState.AuthenticationRequested:
+ Logger.log("d", "Authentication state changed to authentication requested.")
+ self.setAcceptsCommands(False)
+ self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network to {0}. Please approve the access request on the printer.").format(self.name))
+ self._authentication_requested_message.show()
+ self._authentication_request_active = True
+ self._authentication_timer.start() # Start timer so auth will fail after a while.
+ elif auth_state == AuthState.Authenticated:
+ Logger.log("d", "Authentication state changed to authenticated")
+ self.setAcceptsCommands(True)
+ self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network to {0}.").format(self.name))
+ self._authentication_requested_message.hide()
+ if self._authentication_request_active:
+ self._authentication_succeeded_message.show()
+
+ # Stop waiting for a response
+ self._authentication_timer.stop()
+ self._authentication_counter = 0
+
+ # Once we are authenticated we need to send all material profiles.
+ self.sendMaterialProfiles()
+ elif auth_state == AuthState.AuthenticationDenied:
+ self.setAcceptsCommands(False)
+ self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network to {0}. No access to control the printer.").format(self.name))
+ self._authentication_requested_message.hide()
+ if self._authentication_request_active:
+ if self._authentication_timer.remainingTime() > 0:
+ Logger.log("d", "Authentication state changed to authentication denied before the request timeout.")
+ self._authentication_failed_message.setText(i18n_catalog.i18nc("@info:status", "Access request was denied on the printer."))
+ else:
+ Logger.log("d", "Authentication state changed to authentication denied due to a timeout")
+ self._authentication_failed_message.setText(i18n_catalog.i18nc("@info:status", "Access request failed due to a timeout."))
+
+ self._authentication_failed_message.show()
+ self._authentication_request_active = False
+
+ # Stop waiting for a response
+ self._authentication_timer.stop()
+ self._authentication_counter = 0
+
+ if auth_state != self._authentication_state:
+ self._authentication_state = auth_state
+ self.authenticationStateChanged.emit()
+
+ authenticationStateChanged = pyqtSignal()
+
+ @pyqtProperty(int, notify = authenticationStateChanged)
+ def authenticationState(self):
+ return self._authentication_state
+
+ @pyqtSlot()
+ def requestAuthentication(self, message_id = None, action_id = "Retry"):
+ if action_id == "Request" or action_id == "Retry":
+ self._authentication_failed_message.hide()
+ self._not_authenticated_message.hide()
+ self._authentication_state = AuthState.NotAuthenticated
+ self._authentication_counter = 0
+ self._authentication_requested_message.setProgress(0)
+ self._authentication_id = None
+ self._authentication_key = None
+ self._createNetworkManager() # Re-create network manager to force re-authentication.
+
+ ## Request data from the connected device.
+ def _update(self):
+ if self._last_response_time:
+ time_since_last_response = time() - self._last_response_time
+ else:
+ time_since_last_response = 0
+ if self._last_request_time:
+ time_since_last_request = time() - self._last_request_time
+ else:
+ time_since_last_request = float("inf") # An irrelevantly large number of seconds
+
+ # Connection is in timeout, check if we need to re-start the connection.
+ # Sometimes the qNetwork manager incorrectly reports the network status on Mac & Windows.
+ # Re-creating the QNetworkManager seems to fix this issue.
+ if self._last_response_time and self._connection_state_before_timeout:
+ if time_since_last_response > self._recreate_network_manager_time * self._recreate_network_manager_count:
+ self._recreate_network_manager_count += 1
+ counter = 0 # Counter to prevent possible indefinite while loop.
+ # It can happen that we had a very long timeout (multiple times the recreate time).
+ # In that case we should jump through the point that the next update won't be right away.
+ while time_since_last_response - self._recreate_network_manager_time * self._recreate_network_manager_count > self._recreate_network_manager_time and counter < 10:
+ counter += 1
+ self._recreate_network_manager_count += 1
+ Logger.log("d", "Timeout lasted over %.0f seconds (%.1fs), re-checking connection.", self._recreate_network_manager_time, time_since_last_response)
+ self._createNetworkManager()
+ return
+
+ # Check if we have an connection in the first place.
+ if not self._manager.networkAccessible():
+ if not self._connection_state_before_timeout:
+ Logger.log("d", "The network connection seems to be disabled. Going into timeout mode")
+ self._connection_state_before_timeout = self._connection_state
+ self.setConnectionState(ConnectionState.error)
+ self._connection_message = Message(i18n_catalog.i18nc("@info:status",
+ "The connection with the network was lost."))
+ self._connection_message.show()
+
+ if self._progress_message:
+ self._progress_message.hide()
+
+ # Check if we were uploading something. Abort if this is the case.
+ # Some operating systems handle this themselves, others give weird issues.
+ try:
+ if self._post_reply:
+ Logger.log("d", "Stopping post upload because the connection was lost.")
+ try:
+ self._post_reply.uploadProgress.disconnect(self._onUploadProgress)
+ except TypeError:
+ pass # The disconnection can fail on mac in some cases. Ignore that.
+
+ self._post_reply.abort()
+ self._post_reply = None
+ except RuntimeError:
+ self._post_reply = None # It can happen that the wrapped c++ object is already deleted.
+ return
+ else:
+ if not self._connection_state_before_timeout:
+ self._recreate_network_manager_count = 1
+
+ # Check that we aren't in a timeout state
+ if self._last_response_time and self._last_request_time and not self._connection_state_before_timeout:
+ if time_since_last_response > self._response_timeout_time and time_since_last_request <= self._response_timeout_time:
+ # Go into timeout state.
+ Logger.log("d", "We did not receive a response for %0.1f seconds, so it seems the printer is no longer accessible.", time_since_last_response)
+ self._connection_state_before_timeout = self._connection_state
+ self._connection_message = Message(i18n_catalog.i18nc("@info:status", "The connection with the printer was lost. Check your printer to see if it is connected."))
+ self._connection_message.show()
+
+ if self._progress_message:
+ self._progress_message.hide()
+
+ # Check if we were uploading something. Abort if this is the case.
+ # Some operating systems handle this themselves, others give weird issues.
+ try:
+ if self._post_reply:
+ Logger.log("d", "Stopping post upload because the connection was lost.")
+ try:
+ self._post_reply.uploadProgress.disconnect(self._onUploadProgress)
+ except TypeError:
+ pass # The disconnection can fail on mac in some cases. Ignore that.
+
+ self._post_reply.abort()
+ self._post_reply = None
+ except RuntimeError:
+ self._post_reply = None # It can happen that the wrapped c++ object is already deleted.
+ self.setConnectionState(ConnectionState.error)
+ return
+
+ if self._authentication_state == AuthState.NotAuthenticated:
+ self._verifyAuthentication() # We don't know if we are authenticated; check if we have correct auth.
+ elif self._authentication_state == AuthState.AuthenticationRequested:
+ self._checkAuthentication() # We requested authentication at some point. Check if we got permission.
+
+ ## Request 'general' printer data
+ url = QUrl("http://" + self._address + self._api_prefix + "printer")
+ printer_request = QNetworkRequest(url)
+ self._manager.get(printer_request)
+
+ ## Request print_job data
+ url = QUrl("http://" + self._address + self._api_prefix + "print_job")
+ print_job_request = QNetworkRequest(url)
+ self._manager.get(print_job_request)
+
+ self._last_request_time = time()
+
+ def _createNetworkManager(self):
+ if self._manager:
+ self._manager.finished.disconnect(self._onFinished)
+ self._manager.networkAccessibleChanged.disconnect(self._onNetworkAccesibleChanged)
+ self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired)
+
+ self._manager = QNetworkAccessManager()
+ self._manager.finished.connect(self._onFinished)
+ self._manager.authenticationRequired.connect(self._onAuthenticationRequired)
+ self._manager.networkAccessibleChanged.connect(self._onNetworkAccesibleChanged) # for debug purposes
+
+ ## Convenience function that gets information from the received json data and converts it to the right internal
+ # values / variables
+ def _spliceJSONData(self):
+ # Check for hotend temperatures
+ for index in range(0, self._num_extruders):
+ temperature = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["temperature"]["current"]
+ self._setHotendTemperature(index, temperature)
+ try:
+ material_id = self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["guid"]
+ except KeyError:
+ material_id = ""
+ self._setMaterialId(index, material_id)
+ try:
+ hotend_id = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["id"]
+ except KeyError:
+ hotend_id = ""
+ self._setHotendId(index, hotend_id)
+
+ bed_temperature = self._json_printer_state["bed"]["temperature"]["current"]
+ self._setBedTemperature(bed_temperature)
+
+ head_x = self._json_printer_state["heads"][0]["position"]["x"]
+ head_y = self._json_printer_state["heads"][0]["position"]["y"]
+ head_z = self._json_printer_state["heads"][0]["position"]["z"]
+ self._updateHeadPosition(head_x, head_y, head_z)
+ self._updatePrinterState(self._json_printer_state["status"])
+
+
+ def close(self):
+ Logger.log("d", "Closing connection of printer %s with ip %s", self._key, self._address)
+ self._updateJobState("")
+ self.setConnectionState(ConnectionState.closed)
+ if self._progress_message:
+ self._progress_message.hide()
+
+ # Reset authentication state
+ self._authentication_requested_message.hide()
+ self._authentication_state = AuthState.NotAuthenticated
+ self._authentication_counter = 0
+ self._authentication_timer.stop()
+
+ self._authentication_requested_message.hide()
+ self._authentication_failed_message.hide()
+ self._authentication_succeeded_message.hide()
+
+ # Reset stored material & hotend data.
+ self._material_ids = [""] * self._num_extruders
+ self._hotend_ids = [""] * self._num_extruders
+
+ if self._error_message:
+ self._error_message.hide()
+
+ # Reset timeout state
+ self._connection_state_before_timeout = None
+ self._last_response_time = time()
+ self._last_request_time = None
+
+ # Stop update timers
+ self._update_timer.stop()
+
+ self.stopCamera()
+
+ ## Request the current scene to be sent to a network-connected printer.
+ #
+ # \param nodes A collection of scene nodes to send. This is ignored.
+ # \param file_name \type{string} A suggestion for a file name to write.
+ # This is ignored.
+ # \param filter_by_machine Whether to filter MIME types by machine. This
+ # is ignored.
+ def requestWrite(self, nodes, file_name = None, filter_by_machine = False, file_handler = None):
+ if self._progress != 0:
+ self._error_message = Message(i18n_catalog.i18nc("@info:status", "Unable to start a new print job because the printer is busy. Please check the printer."))
+ self._error_message.show()
+ return
+ if self._printer_state != "idle":
+ self._error_message = Message(
+ i18n_catalog.i18nc("@info:status", "Unable to start a new print job, printer is busy. Current printer status is %s.") % self._printer_state)
+ self._error_message.show()
+ return
+ elif self._authentication_state != AuthState.Authenticated:
+ self._not_authenticated_message.show()
+ Logger.log("d", "Attempting to perform an action without authentication. Auth state is %s", self._authentication_state)
+ return
+
+ Application.getInstance().showPrintMonitor.emit(True)
+ self._print_finished = True
+ self._gcode = getattr(Application.getInstance().getController().getScene(), "gcode_list")
+
+ print_information = Application.getInstance().getPrintInformation()
+
+ # Check if PrintCores / materials are loaded at all. Any failure in these results in an Error.
+ for index in range(0, self._num_extruders):
+ if print_information.materialLengths[index] != 0:
+ if self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["id"] == "":
+ Logger.log("e", "No cartridge loaded in slot %s, unable to start print", index + 1)
+ self._error_message = Message(
+ i18n_catalog.i18nc("@info:status", "Unable to start a new print job. No PrinterCore loaded in slot {0}".format(index + 1)))
+ self._error_message.show()
+ return
+ if self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["guid"] == "":
+ Logger.log("e", "No material loaded in slot %s, unable to start print", index + 1)
+ self._error_message = Message(
+ i18n_catalog.i18nc("@info:status",
+ "Unable to start a new print job. No material loaded in slot {0}".format(index + 1)))
+ self._error_message.show()
+ return
+
+ warnings = [] # There might be multiple things wrong. Keep a list of all the stuff we need to warn about.
+
+ for index in range(0, self._num_extruders):
+ # Check if there is enough material. Any failure in these results in a warning.
+ material_length = self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["length_remaining"]
+ if material_length != -1 and print_information.materialLengths[index] > material_length:
+ Logger.log("w", "Printer reports that there is not enough material left for extruder %s. We need %s and the printer has %s", index + 1, print_information.materialLengths[index], material_length)
+ warnings.append(i18n_catalog.i18nc("@label", "Not enough material for spool {0}.").format(index+1))
+
+ # Check if the right cartridges are loaded. Any failure in these results in a warning.
+ extruder_manager = cura.Settings.ExtruderManager.getInstance()
+ if print_information.materialLengths[index] != 0:
+ variant = extruder_manager.getExtruderStack(index).findContainer({"type": "variant"})
+ core_name = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["id"]
+ if variant:
+ if variant.getName() != core_name:
+ Logger.log("w", "Extruder %s has a different Cartridge (%s) as Cura (%s)", index + 1, core_name, variant.getName())
+ warnings.append(i18n_catalog.i18nc("@label", "Different PrintCore (Cura: {0}, Printer: {1}) selected for extruder {2}".format(variant.getName(), core_name, index + 1)))
+
+ material = extruder_manager.getExtruderStack(index).findContainer({"type": "material"})
+ if material:
+ remote_material_guid = self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["guid"]
+ if material.getMetaDataEntry("GUID") != remote_material_guid:
+ Logger.log("w", "Extruder %s has a different material (%s) as Cura (%s)", index + 1,
+ remote_material_guid,
+ material.getMetaDataEntry("GUID"))
+
+ remote_materials = UM.Settings.ContainerRegistry.getInstance().findInstanceContainers(type = "material", GUID = remote_material_guid, read_only = True)
+ remote_material_name = "Unknown"
+ if remote_materials:
+ remote_material_name = remote_materials[0].getName()
+ warnings.append(i18n_catalog.i18nc("@label", "Different material (Cura: {0}, Printer: {1}) selected for extruder {2}").format(material.getName(), remote_material_name, index + 1))
+
+ try:
+ is_offset_calibrated = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["offset"]["state"] == "valid"
+ except KeyError: # Older versions of the API don't expose the offset property, so we must asume that all is well.
+ is_offset_calibrated = True
+
+ if not is_offset_calibrated:
+ warnings.append(i18n_catalog.i18nc("@label", "PrintCore {0} is not properly calibrated. XY calibration needs to be performed on the printer.").format(index + 1))
+
+ if warnings:
+ text = i18n_catalog.i18nc("@label", "Are you sure you wish to print with the selected configuration?")
+ informative_text = i18n_catalog.i18nc("@label", "There is a mismatch between the configuration or calibration of the printer and Cura. "
+ "For the best result, always slice for the PrintCores and materials that are inserted in your printer.")
+ detailed_text = ""
+ for warning in warnings:
+ detailed_text += warning + "\n"
+
+ Application.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Mismatched configuration"),
+ text,
+ informative_text,
+ detailed_text,
+ buttons=QMessageBox.Yes + QMessageBox.No,
+ icon=QMessageBox.Question,
+ callback=self._configurationMismatchMessageCallback
+ )
+ return
+
+ self.startPrint()
+
+ def _configurationMismatchMessageCallback(self, button):
+ def delayedCallback():
+ if button == QMessageBox.Yes:
+ self.startPrint()
+ else:
+ Application.getInstance().showPrintMonitor.emit(False)
+ # For some unknown reason Cura on OSX will hang if we do the call back code
+ # immediately without first returning and leaving QML's event system.
+ QTimer.singleShot(100, delayedCallback)
+
+ def isConnected(self):
+ return self._connection_state != ConnectionState.closed and self._connection_state != ConnectionState.error
+
+ ## Start requesting data from printer
+ def connect(self):
+ self.close() # Ensure that previous connection (if any) is killed.
+
+ self._createNetworkManager()
+
+ self.setConnectionState(ConnectionState.connecting)
+ self._update() # Manually trigger the first update, as we don't want to wait a few secs before it starts.
+ if not self._use_stream:
+ self._updateCamera()
+ Logger.log("d", "Connection with printer %s with ip %s started", self._key, self._address)
+
+ ## Check if this machine was authenticated before.
+ self._authentication_id = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("network_authentication_id", None)
+ self._authentication_key = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("network_authentication_key", None)
+
+ self._update_timer.start()
+ #self.startCamera()
+
+ ## Stop requesting data from printer
+ def disconnect(self):
+ Logger.log("d", "Connection with printer %s with ip %s stopped", self._key, self._address)
+ self.close()
+
+ newImage = pyqtSignal()
+
+ @pyqtProperty(QUrl, notify = newImage)
+ def cameraImage(self):
+ self._camera_image_id += 1
+ # There is an image provider that is called "camera". In order to ensure that the image qml object, that
+ # requires a QUrl to function, updates correctly we add an increasing number. This causes to see the QUrl
+ # as new (instead of relying on cached version and thus forces an update.
+ temp = "image://camera/" + str(self._camera_image_id)
+ return QUrl(temp, QUrl.TolerantMode)
+
+ def getCameraImage(self):
+ return self._camera_image
+
+ def _setJobState(self, job_state):
+ self._last_command = job_state
+ url = QUrl("http://" + self._address + self._api_prefix + "print_job/state")
+ put_request = QNetworkRequest(url)
+ put_request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json")
+ data = "{\"target\": \"%s\"}" % job_state
+ self._manager.put(put_request, data.encode())
+
+ ## Convenience function to get the username from the OS.
+ # The code was copied from the getpass module, as we try to use as little dependencies as possible.
+ def _getUserName(self):
+ for name in ("LOGNAME", "USER", "LNAME", "USERNAME"):
+ user = os.environ.get(name)
+ if user:
+ return user
+ return "Unknown User" # Couldn't find out username.
+
+ def _progressMessageActionTrigger(self, message_id = None, action_id = None):
+ if action_id == "Abort":
+ Logger.log("d", "User aborted sending print to remote.")
+ self._progress_message.hide()
+ self._compressing_print = False
+ if self._post_reply:
+ self._post_reply.abort()
+ self._post_reply = None
+ Application.getInstance().showPrintMonitor.emit(False)
+
+ ## Attempt to start a new print.
+ # This function can fail to actually start a print due to not being authenticated or another print already
+ # being in progress.
+ def startPrint(self):
+ try:
+ self._send_gcode_start = time()
+ self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), 0, False, -1)
+ self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), None, "")
+ self._progress_message.actionTriggered.connect(self._progressMessageActionTrigger)
+ self._progress_message.show()
+ Logger.log("d", "Started sending g-code to remote printer.")
+ self._compressing_print = True
+ ## Mash the data into single string
+ byte_array_file_data = b""
+ for line in self._gcode:
+ if not self._compressing_print:
+ self._progress_message.hide()
+ return # Stop trying to zip, abort was called.
+ if self._use_gzip:
+ byte_array_file_data += gzip.compress(line.encode("utf-8"))
+ QCoreApplication.processEvents() # Ensure that the GUI does not freeze.
+ # Pretend that this is a response, as zipping might take a bit of time.
+ self._last_response_time = time()
+ else:
+ byte_array_file_data += line.encode("utf-8")
+
+ if self._use_gzip:
+ file_name = "%s.gcode.gz" % Application.getInstance().getPrintInformation().jobName
+ else:
+ file_name = "%s.gcode" % Application.getInstance().getPrintInformation().jobName
+
+ self._compressing_print = False
+ ## Create multi_part request
+ self._post_multi_part = QHttpMultiPart(QHttpMultiPart.FormDataType)
+
+ ## Create part (to be placed inside multipart)
+ self._post_part = QHttpPart()
+ self._post_part.setHeader(QNetworkRequest.ContentDispositionHeader,
+ "form-data; name=\"file\"; filename=\"%s\"" % file_name)
+ self._post_part.setBody(byte_array_file_data)
+ self._post_multi_part.append(self._post_part)
+
+ url = QUrl("http://" + self._address + self._api_prefix + "print_job")
+
+ ## Create the QT request
+ self._post_request = QNetworkRequest(url)
+
+ ## Post request + data
+ self._post_reply = self._manager.post(self._post_request, self._post_multi_part)
+ self._post_reply.uploadProgress.connect(self._onUploadProgress)
+
+ except IOError:
+ self._progress_message.hide()
+ self._error_message = Message(i18n_catalog.i18nc("@info:status", "Unable to send data to printer. Is another job still active?"))
+ self._error_message.show()
+ except Exception as e:
+ self._progress_message.hide()
+ Logger.log("e", "An exception occurred in network connection: %s" % str(e))
+
+ ## Verify if we are authenticated to make requests.
+ def _verifyAuthentication(self):
+ url = QUrl("http://" + self._address + self._api_prefix + "auth/verify")
+ request = QNetworkRequest(url)
+ self._manager.get(request)
+
+ ## Check if the authentication request was allowed by the printer.
+ def _checkAuthentication(self):
+ Logger.log("d", "Checking if authentication is correct.")
+ self._manager.get(QNetworkRequest(QUrl("http://" + self._address + self._api_prefix + "auth/check/" + str(self._authentication_id))))
+
+ ## Request a authentication key from the printer so we can be authenticated
+ def _requestAuthentication(self):
+ url = QUrl("http://" + self._address + self._api_prefix + "auth/request")
+ request = QNetworkRequest(url)
+ request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json")
+ self.setAuthenticationState(AuthState.AuthenticationRequested)
+ self._manager.post(request, json.dumps({"application": "Cura-" + Application.getInstance().getVersion(), "user": self._getUserName()}).encode())
+
+ ## Send all material profiles to the printer.
+ def sendMaterialProfiles(self):
+ for container in UM.Settings.ContainerRegistry.getInstance().findInstanceContainers(type = "material"):
+ try:
+ xml_data = container.serialize()
+ if xml_data == "" or xml_data is None:
+ continue
+ material_multi_part = QHttpMultiPart(QHttpMultiPart.FormDataType)
+
+ material_part = QHttpPart()
+ file_name = "none.xml"
+ material_part.setHeader(QNetworkRequest.ContentDispositionHeader, "form-data; name=\"file\";filename=\"%s\"" % file_name)
+ material_part.setBody(xml_data.encode())
+ material_multi_part.append(material_part)
+ url = QUrl("http://" + self._address + self._api_prefix + "materials")
+ material_post_request = QNetworkRequest(url)
+ reply = self._manager.post(material_post_request, material_multi_part)
+
+ # Keep reference to material_part, material_multi_part and reply so the garbage collector won't touch them.
+ self._material_post_objects[id(reply)] = (material_part, material_multi_part, reply)
+ except NotImplementedError:
+ # If the material container is not the most "generic" one it can't be serialized an will raise a
+ # NotImplementedError. We can simply ignore these.
+ pass
+
+ ## Handler for all requests that have finished.
+ def _onFinished(self, reply):
+ if reply.error() == QNetworkReply.TimeoutError:
+ Logger.log("w", "Received a timeout on a request to the printer")
+ self._connection_state_before_timeout = self._connection_state
+ # Check if we were uploading something. Abort if this is the case.
+ # Some operating systems handle this themselves, others give weird issues.
+ if self._post_reply:
+ self._post_reply.abort()
+ self._post_reply.uploadProgress.disconnect(self._onUploadProgress)
+ Logger.log("d", "Uploading of print failed after %s", time() - self._send_gcode_start)
+ self._post_reply = None
+ self._progress_message.hide()
+
+ self.setConnectionState(ConnectionState.error)
+ return
+
+ if self._connection_state_before_timeout and reply.error() == QNetworkReply.NoError: # There was a timeout, but we got a correct answer again.
+ Logger.log("d", "We got a response (%s) from the server after %0.1f of silence. Going back to previous state %s", reply.url().toString(), time() - self._last_response_time, self._connection_state_before_timeout)
+
+ # Camera was active before timeout. Start it again
+ if self._camera_active:
+ self._startCamera()
+
+ self.setConnectionState(self._connection_state_before_timeout)
+ self._connection_state_before_timeout = None
+
+ if reply.error() == QNetworkReply.NoError:
+ self._last_response_time = time()
+
+ status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
+ if not status_code:
+ if self._connection_state != ConnectionState.error:
+ Logger.log("d", "A reply from %s did not have status code.", reply.url().toString())
+ # Received no or empty reply
+ return
+ reply_url = reply.url().toString()
+
+ if reply.operation() == QNetworkAccessManager.GetOperation:
+ if "printer" in reply_url: # Status update from printer.
+ if status_code == 200:
+ if self._connection_state == ConnectionState.connecting:
+ self.setConnectionState(ConnectionState.connected)
+ self._json_printer_state = json.loads(bytes(reply.readAll()).decode("utf-8"))
+ self._spliceJSONData()
+
+ # Hide connection error message if the connection was restored
+ if self._connection_message:
+ self._connection_message.hide()
+ self._connection_message = None
+ else:
+ Logger.log("w", "We got an unexpected status (%s) while requesting printer state", status_code)
+ pass # TODO: Handle errors
+ elif "print_job" in reply_url: # Status update from print_job:
+ if status_code == 200:
+ json_data = json.loads(bytes(reply.readAll()).decode("utf-8"))
+ progress = json_data["progress"]
+ ## If progress is 0 add a bit so another print can't be sent.
+ if progress == 0:
+ progress += 0.001
+ elif progress == 1:
+ self._print_finished = True
+ else:
+ self._print_finished = False
+ self.setProgress(progress * 100)
+
+ state = json_data["state"]
+
+ # There is a short period after aborting or finishing a print where the printer
+ # reports a "none" state (but the printer is not ready to receive a print)
+ # If this happens before the print has reached progress == 1, the print has
+ # been aborted.
+ if state == "none" or state == "":
+ if self._last_command == "abort":
+ self.setErrorText(i18n_catalog.i18nc("@label:MonitorStatus", "Aborting print..."))
+ state = "error"
+ else:
+ state = "printing"
+ if state == "wait_cleanup" and self._last_command == "abort":
+ # Keep showing the "aborted" error state until after the buildplate has been cleaned
+ self.setErrorText(i18n_catalog.i18nc("@label:MonitorStatus", "Print aborted. Please check the printer"))
+ state = "error"
+
+ # NB/TODO: the following two states are intentionally added for future proofing the i18n strings
+ # but are currently non-functional
+ if state == "!pausing":
+ self.setErrorText(i18n_catalog.i18nc("@label:MonitorStatus", "Pausing print..."))
+ if state == "!resuming":
+ self.setErrorText(i18n_catalog.i18nc("@label:MonitorStatus", "Resuming print..."))
+
+ self._updateJobState(state)
+ self.setTimeElapsed(json_data["time_elapsed"])
+ self.setTimeTotal(json_data["time_total"])
+ self.setJobName(json_data["name"])
+ elif status_code == 404:
+ self.setProgress(0) # No print job found, so there can't be progress or other data.
+ self._updateJobState("")
+ self.setErrorText("")
+ self.setTimeElapsed(0)
+ self.setTimeTotal(0)
+ self.setJobName("")
+ else:
+ Logger.log("w", "We got an unexpected status (%s) while requesting print job state", status_code)
+ elif "snapshot" in reply_url: # Status update from image:
+ if status_code == 200:
+ self._camera_image.loadFromData(reply.readAll())
+ self.newImage.emit()
+ elif "auth/verify" in reply_url: # Answer when requesting authentication
+ if status_code == 401:
+ if self._authentication_state != AuthState.AuthenticationRequested:
+ # Only request a new authentication when we have not already done so.
+ Logger.log("i", "Not authenticated. Attempting to request authentication")
+ self._requestAuthentication()
+ elif status_code == 403:
+ # If we already had an auth (eg; didn't request one), we only need a single 403 to see it as denied.
+ if self._authentication_state != AuthState.AuthenticationRequested:
+ Logger.log("d", "While trying to verify the authentication state, we got a forbidden response. Our own auth state was %s", self._authentication_state)
+ self.setAuthenticationState(AuthState.AuthenticationDenied)
+ elif status_code == 200:
+ self.setAuthenticationState(AuthState.Authenticated)
+ global_container_stack = Application.getInstance().getGlobalContainerStack()
+ ## Save authentication details.
+ if global_container_stack:
+ if "network_authentication_key" in global_container_stack.getMetaData():
+ global_container_stack.setMetaDataEntry("network_authentication_key", self._authentication_key)
+ else:
+ global_container_stack.addMetaDataEntry("network_authentication_key", self._authentication_key)
+ if "network_authentication_id" in global_container_stack.getMetaData():
+ global_container_stack.setMetaDataEntry("network_authentication_id", self._authentication_id)
+ else:
+ global_container_stack.addMetaDataEntry("network_authentication_id", self._authentication_id)
+ Application.getInstance().saveStack(global_container_stack) # Force save so we are sure the data is not lost.
+ Logger.log("i", "Authentication succeeded")
+ else: # Got a response that we didn't expect, so something went wrong.
+ Logger.log("w", "While trying to authenticate, we got an unexpected response: %s", reply.attribute(QNetworkRequest.HttpStatusCodeAttribute))
+ self.setAuthenticationState(AuthState.NotAuthenticated)
+
+ elif "auth/check" in reply_url: # Check if we are authenticated (user can refuse this!)
+ data = json.loads(bytes(reply.readAll()).decode("utf-8"))
+ if data.get("message", "") == "authorized":
+ Logger.log("i", "Authentication was approved")
+ self._verifyAuthentication() # Ensure that the verification is really used and correct.
+ elif data.get("message", "") == "unauthorized":
+ Logger.log("i", "Authentication was denied.")
+ self.setAuthenticationState(AuthState.AuthenticationDenied)
+ else:
+ pass
+
+ elif reply.operation() == QNetworkAccessManager.PostOperation:
+ if "/auth/request" in reply_url:
+ # We got a response to requesting authentication.
+ data = json.loads(bytes(reply.readAll()).decode("utf-8"))
+
+ global_container_stack = Application.getInstance().getGlobalContainerStack()
+ if global_container_stack: # Remove any old data.
+ global_container_stack.removeMetaDataEntry("network_authentication_key")
+ global_container_stack.removeMetaDataEntry("network_authentication_id")
+ Application.getInstance().saveStack(global_container_stack) # Force saving so we don't keep wrong auth data.
+
+ self._authentication_key = data["key"]
+ self._authentication_id = data["id"]
+ Logger.log("i", "Got a new authentication ID. Waiting for authorization: %s", self._authentication_id )
+
+ # Check if the authentication is accepted.
+ self._checkAuthentication()
+ elif "materials" in reply_url:
+ # Remove cached post request items.
+ del self._material_post_objects[id(reply)]
+ elif "print_job" in reply_url:
+ reply.uploadProgress.disconnect(self._onUploadProgress)
+ Logger.log("d", "Uploading of print succeeded after %s", time() - self._send_gcode_start)
+ # Only reset the _post_reply if it was the same one.
+ if reply == self._post_reply:
+ self._post_reply = None
+ self._progress_message.hide()
+
+ elif reply.operation() == QNetworkAccessManager.PutOperation:
+ if status_code == 204:
+ pass # Request was successful!
+ else:
+ Logger.log("d", "Something went wrong when trying to update data of API (%s). Message: %s Statuscode: %s", reply_url, reply.readAll(), status_code)
+ else:
+ Logger.log("d", "NetworkPrinterOutputDevice got an unhandled operation %s", reply.operation())
+
+ def _onStreamDownloadProgress(self, bytes_received, bytes_total):
+ # An MJPG stream is (for our purpose) a stream of concatenated JPG images.
+ # JPG images start with the marker 0xFFD8, and end with 0xFFD9
+ if self._image_reply is None:
+ return
+ self._stream_buffer += self._image_reply.readAll()
+
+ if self._stream_buffer_start_index == -1:
+ self._stream_buffer_start_index = self._stream_buffer.indexOf(b'\xff\xd8')
+ stream_buffer_end_index = self._stream_buffer.lastIndexOf(b'\xff\xd9')
+ # If this happens to be more than a single frame, then so be it; the JPG decoder will
+ # ignore the extra data. We do it like this in order not to get a buildup of frames
+
+ if self._stream_buffer_start_index != -1 and stream_buffer_end_index != -1:
+ jpg_data = self._stream_buffer[self._stream_buffer_start_index:stream_buffer_end_index + 2]
+ self._stream_buffer = self._stream_buffer[stream_buffer_end_index + 2:]
+ self._stream_buffer_start_index = -1
+
+ self._camera_image.loadFromData(jpg_data)
+ self.newImage.emit()
+
+ def _onUploadProgress(self, bytes_sent, bytes_total):
+ if bytes_total > 0:
+ new_progress = bytes_sent / bytes_total * 100
+ # Treat upload progress as response. Uploading can take more than 10 seconds, so if we don't, we can get
+ # timeout responses if this happens.
+ self._last_response_time = time()
+ if new_progress > self._progress_message.getProgress():
+ self._progress_message.show() # Ensure that the message is visible.
+ self._progress_message.setProgress(bytes_sent / bytes_total * 100)
+ else:
+ self._progress_message.setProgress(0)
+ self._progress_message.hide()
+
+ ## Let the user decide if the hotends and/or material should be synced with the printer
+ def materialHotendChangedMessage(self, callback):
+ Application.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Changes on the Printer"),
+ i18n_catalog.i18nc("@label",
+ "Would you like to update your current printer configuration into Cura?"),
+ i18n_catalog.i18nc("@label",
+ "The PrintCores and/or materials on your printer were changed. For the best result, always slice for the PrintCores and materials that are inserted in your printer."),
+ buttons=QMessageBox.Yes + QMessageBox.No,
+ icon=QMessageBox.Question,
+ callback=callback
+ )
diff --git a/plugins/UM3NetworkPrinting/NetworkPrinterOutputDevicePlugin.py b/plugins/UM3NetworkPrinting/NetworkPrinterOutputDevicePlugin.py
new file mode 100644
index 0000000000..7a86002210
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/NetworkPrinterOutputDevicePlugin.py
@@ -0,0 +1,205 @@
+from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
+from . import NetworkPrinterOutputDevice
+
+from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo
+from UM.Logger import Logger
+from UM.Signal import Signal, signalemitter
+from UM.Application import Application
+from UM.Preferences import Preferences
+
+from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager, QNetworkReply
+from PyQt5.QtCore import QUrl
+
+import time
+import json
+
+## This plugin handles the connection detection & creation of output device objects for the UM3 printer.
+# Zero-Conf is used to detect printers, which are saved in a dict.
+# If we discover a printer that has the same key as the active machine instance a connection is made.
+@signalemitter
+class NetworkPrinterOutputDevicePlugin(OutputDevicePlugin):
+ def __init__(self):
+ super().__init__()
+ self._zero_conf = None
+ self._browser = None
+ self._printers = {}
+
+ self._api_version = "1"
+ self._api_prefix = "/api/v" + self._api_version + "/"
+
+ self._network_manager = QNetworkAccessManager()
+ self._network_manager.finished.connect(self._onNetworkRequestFinished)
+
+ # List of old printer names. This is used to ensure that a refresh of zeroconf does not needlessly forces
+ # authentication requests.
+ self._old_printers = []
+
+ # Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
+ self.addPrinterSignal.connect(self.addPrinter)
+ self.removePrinterSignal.connect(self.removePrinter)
+ Application.getInstance().globalContainerStackChanged.connect(self.reCheckConnections)
+
+ # Get list of manual printers from preferences
+ self._preferences = Preferences.getInstance()
+ self._preferences.addPreference("um3networkprinting/manual_instances", "") # A comma-separated list of ip adresses or hostnames
+ self._manual_instances = self._preferences.getValue("um3networkprinting/manual_instances").split(",")
+
+ addPrinterSignal = Signal()
+ removePrinterSignal = Signal()
+ printerListChanged = Signal()
+
+ ## Start looking for devices on network.
+ def start(self):
+ self.startDiscovery()
+
+ def startDiscovery(self):
+ self.stop()
+ if self._browser:
+ self._browser.cancel()
+ self._browser = None
+ self._old_printers = [printer_name for printer_name in self._printers]
+ self._printers = {}
+ self.printerListChanged.emit()
+ # After network switching, one must make a new instance of Zeroconf
+ # On windows, the instance creation is very fast (unnoticable). Other platforms?
+ self._zero_conf = Zeroconf()
+ self._browser = ServiceBrowser(self._zero_conf, u'_ultimaker._tcp.local.', [self._onServiceChanged])
+
+ # Look for manual instances from preference
+ for address in self._manual_instances:
+ if address:
+ self.addManualPrinter(address)
+
+ def addManualPrinter(self, address):
+ if address not in self._manual_instances:
+ self._manual_instances.append(address)
+ self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances))
+
+ name = address
+ instance_name = "manual:%s" % address
+ properties = { b"name": name.encode("utf-8"), b"manual": b"true", b"incomplete": b"true" }
+
+ if instance_name not in self._printers:
+ # Add a preliminary printer instance
+ self.addPrinter(instance_name, address, properties)
+
+ self.checkManualPrinter(address)
+
+ def removeManualPrinter(self, key, address = None):
+ if key in self._printers:
+ if not address:
+ address = self._printers[key].ipAddress
+ self.removePrinter(key)
+
+ if address in self._manual_instances:
+ self._manual_instances.remove(address)
+ self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances))
+
+ def checkManualPrinter(self, address):
+ # Check if a printer exists at this address
+ # If a printer responds, it will replace the preliminary printer created above
+ url = QUrl("http://" + address + self._api_prefix + "system")
+ name_request = QNetworkRequest(url)
+ self._network_manager.get(name_request)
+
+ ## Handler for all requests that have finished.
+ def _onNetworkRequestFinished(self, reply):
+ reply_url = reply.url().toString()
+ status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
+
+ if reply.operation() == QNetworkAccessManager.GetOperation:
+ if "system" in reply_url: # Name returned from printer.
+ if status_code == 200:
+ system_info = json.loads(bytes(reply.readAll()).decode("utf-8"))
+ address = reply.url().host()
+ name = ("%s (%s)" % (system_info["name"], address))
+
+ instance_name = "manual:%s" % address
+ properties = { b"name": name.encode("utf-8"), b"firmware_version": system_info["firmware"].encode("utf-8"), b"manual": b"true" }
+ if instance_name in self._printers:
+ # Only replace the printer if it is still in the list of (manual) printers
+ self.removePrinter(instance_name)
+ self.addPrinter(instance_name, address, properties)
+
+ ## Stop looking for devices on network.
+ def stop(self):
+ if self._zero_conf is not None:
+ self._zero_conf.close()
+
+ def getPrinters(self):
+ return self._printers
+
+ def reCheckConnections(self):
+ active_machine = Application.getInstance().getGlobalContainerStack()
+ if not active_machine:
+ return
+
+ for key in self._printers:
+ if key == active_machine.getMetaDataEntry("um_network_key"):
+ self._printers[key].connect()
+ self._printers[key].connectionStateChanged.connect(self._onPrinterConnectionStateChanged)
+ else:
+ if self._printers[key].isConnected():
+ self._printers[key].close()
+
+ ## Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
+ def addPrinter(self, name, address, properties):
+ printer = NetworkPrinterOutputDevice.NetworkPrinterOutputDevice(name, address, properties, self._api_prefix)
+ self._printers[printer.getKey()] = printer
+ global_container_stack = Application.getInstance().getGlobalContainerStack()
+ if global_container_stack and printer.getKey() == global_container_stack.getMetaDataEntry("um_network_key"):
+ if printer.getKey() not in self._old_printers: # Was the printer already connected, but a re-scan forced?
+ self._printers[printer.getKey()].connect()
+ printer.connectionStateChanged.connect(self._onPrinterConnectionStateChanged)
+ self.printerListChanged.emit()
+
+ def removePrinter(self, name):
+ printer = self._printers.pop(name, None)
+ if printer:
+ if printer.isConnected():
+ printer.connectionStateChanged.disconnect(self._onPrinterConnectionStateChanged)
+ printer.disconnect()
+ self.printerListChanged.emit()
+
+ ## Handler for when the connection state of one of the detected printers changes
+ def _onPrinterConnectionStateChanged(self, key):
+ if key not in self._printers:
+ return
+ if self._printers[key].isConnected():
+ self.getOutputDeviceManager().addOutputDevice(self._printers[key])
+ else:
+ self.getOutputDeviceManager().removeOutputDevice(key)
+
+ ## Handler for zeroConf detection
+ def _onServiceChanged(self, zeroconf, service_type, name, state_change):
+ if state_change == ServiceStateChange.Added:
+ Logger.log("d", "Bonjour service added: %s" % name)
+
+ # First try getting info from zeroconf cache
+ info = ServiceInfo(service_type, name, properties = {})
+ for record in zeroconf.cache.entries_with_name(name.lower()):
+ info.update_record(zeroconf, time.time(), record)
+
+ for record in zeroconf.cache.entries_with_name(info.server):
+ info.update_record(zeroconf, time.time(), record)
+ if info.address:
+ break
+
+ # Request more data if info is not complete
+ if not info.address:
+ Logger.log("d", "Trying to get address of %s", name)
+ info = zeroconf.get_service_info(service_type, name)
+
+ if info:
+ type_of_device = info.properties.get(b"type", None).decode("utf-8")
+ if type_of_device == "printer":
+ address = '.'.join(map(lambda n: str(n), info.address))
+ self.addPrinterSignal.emit(str(name), address, info.properties)
+ else:
+ Logger.log("w", "The type of the found device is '%s', not 'printer'! Ignoring.." %type_of_device )
+ else:
+ Logger.log("w", "Could not get information about %s" % name)
+
+ elif state_change == ServiceStateChange.Removed:
+ Logger.log("d", "Bonjour service removed: %s" % name)
+ self.removePrinterSignal.emit(str(name))
\ No newline at end of file
diff --git a/plugins/UM3NetworkPrinting/UM3InfoComponents.qml b/plugins/UM3NetworkPrinting/UM3InfoComponents.qml
new file mode 100644
index 0000000000..a5ed944773
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/UM3InfoComponents.qml
@@ -0,0 +1,124 @@
+import UM 1.2 as UM
+import Cura 1.0 as Cura
+
+import QtQuick 2.2
+import QtQuick.Controls 1.1
+import QtQuick.Layouts 1.1
+import QtQuick.Window 2.1
+
+Item
+{
+ id: base
+
+ property bool isUM3: Cura.MachineManager.activeQualityDefinitionId == "ultimaker3"
+ property bool printerConnected: Cura.MachineManager.printerOutputDevices.length != 0
+ property bool printerAcceptsCommands: printerConnected && Cura.MachineManager.printerOutputDevices[0].acceptsCommands
+ property bool authenticationRequested: printerConnected && Cura.MachineManager.printerOutputDevices[0].authenticationState == 2 // AuthState.AuthenticationRequested
+
+ Row
+ {
+ objectName: "networkPrinterConnectButton"
+ visible: isUM3
+ spacing: UM.Theme.getSize("default_margin").width
+
+ Button
+ {
+ height: UM.Theme.getSize("save_button_save_to_button").height
+ tooltip: catalog.i18nc("@info:tooltip", "Send access request to the printer")
+ text: catalog.i18nc("@action:button", "Request Access")
+ style: UM.Theme.styles.sidebar_action_button
+ onClicked: Cura.MachineManager.printerOutputDevices[0].requestAuthentication()
+ visible: printerConnected && !printerAcceptsCommands && !authenticationRequested
+ }
+
+ Button
+ {
+ height: UM.Theme.getSize("save_button_save_to_button").height
+ tooltip: catalog.i18nc("@info:tooltip", "Connect to a printer")
+ text: catalog.i18nc("@action:button", "Connect")
+ style: UM.Theme.styles.sidebar_action_button
+ onClicked: connectActionDialog.show()
+ visible: !printerConnected
+ }
+ }
+
+ UM.Dialog
+ {
+ id: connectActionDialog
+ Loader
+ {
+ anchors.fill: parent
+ source: "DiscoverUM3Action.qml"
+ }
+ rightButtons: Button
+ {
+ text: catalog.i18nc("@action:button", "Close")
+ iconName: "dialog-close"
+ onClicked: connectActionDialog.reject()
+ }
+ }
+
+
+ Column
+ {
+ objectName: "networkPrinterConnectionInfo"
+ visible: isUM3
+ spacing: UM.Theme.getSize("default_margin").width
+ anchors.fill: parent
+
+ Button
+ {
+ tooltip: catalog.i18nc("@info:tooltip", "Send access request to the printer")
+ text: catalog.i18nc("@action:button", "Request Access")
+ onClicked: Cura.MachineManager.printerOutputDevices[0].requestAuthentication()
+ visible: printerConnected && !printerAcceptsCommands && !authenticationRequested
+ }
+
+ Row
+ {
+ visible: printerConnected
+ spacing: UM.Theme.getSize("default_margin").width
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: childrenRect.height
+
+ Column
+ {
+ Repeater
+ {
+ model: Cura.ExtrudersModel { simpleNames: true }
+ Label { text: model.name }
+ }
+ }
+ Column
+ {
+ Repeater
+ {
+ id: nozzleColumn
+ model: printerConnected ? Cura.MachineManager.printerOutputDevices[0].hotendIds : null
+ Label { text: nozzleColumn.model[index] }
+ }
+ }
+ Column
+ {
+ Repeater
+ {
+ id: materialColumn
+ model: printerConnected ? Cura.MachineManager.printerOutputDevices[0].materialNames : null
+ Label { text: materialColumn.model[index] }
+ }
+ }
+ }
+
+ Button
+ {
+ tooltip: catalog.i18nc("@info:tooltip", "Load the configuration of the printer into Cura")
+ text: catalog.i18nc("@action:button", "Activate Configuration")
+ visible: printerConnected
+ onClicked: manager.loadConfigurationFromPrinter()
+ }
+ }
+
+ UM.I18nCatalog{id: catalog; name:"cura"}
+}
\ No newline at end of file
diff --git a/plugins/UM3NetworkPrinting/__init__.py b/plugins/UM3NetworkPrinting/__init__.py
new file mode 100644
index 0000000000..be9f1195ec
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/__init__.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2015 Ultimaker B.V.
+# Cura is released under the terms of the AGPLv3 or higher.
+from . import NetworkPrinterOutputDevicePlugin
+from . import DiscoverUM3Action
+from UM.i18n import i18nCatalog
+catalog = i18nCatalog("cura")
+
+def getMetaData():
+ return {
+ "plugin": {
+ "name": "UM3 Network Connection",
+ "author": "Ultimaker",
+ "description": catalog.i18nc("@info:whatsthis", "Manages network connections to Ultimaker 3 printers"),
+ "version": "1.0",
+ "api": 3
+ }
+ }
+
+def register(app):
+ return { "output_device": NetworkPrinterOutputDevicePlugin.NetworkPrinterOutputDevicePlugin(), "machine_action": DiscoverUM3Action.DiscoverUM3Action()}
\ No newline at end of file
diff --git a/plugins/USBPrinting/USBPrinterOutputDevice.py b/plugins/USBPrinting/USBPrinterOutputDevice.py
index 13dfe967b3..7d8a11521d 100644
--- a/plugins/USBPrinting/USBPrinterOutputDevice.py
+++ b/plugins/USBPrinting/USBPrinterOutputDevice.py
@@ -432,7 +432,7 @@ class USBPrinterOutputDevice(PrinterOutputDevice):
# This is ignored.
# \param filter_by_machine Whether to filter MIME types by machine. This
# is ignored.
- def requestWrite(self, nodes, file_name = None, filter_by_machine = False):
+ def requestWrite(self, nodes, file_name = None, filter_by_machine = False, file_handler = None):
Application.getInstance().showPrintMonitor.emit(True)
self.startPrint()
diff --git a/plugins/UltimakerMachineActions/UMOUpgradeSelection.py b/plugins/UltimakerMachineActions/UMOUpgradeSelection.py
index b92dc30c68..e85ec7b434 100644
--- a/plugins/UltimakerMachineActions/UMOUpgradeSelection.py
+++ b/plugins/UltimakerMachineActions/UMOUpgradeSelection.py
@@ -27,19 +27,23 @@ class UMOUpgradeSelection(MachineAction):
def setHeatedBed(self, heated_bed = True):
global_container_stack = Application.getInstance().getGlobalContainerStack()
if global_container_stack:
- variant = global_container_stack.findContainer({"type": "variant"})
- if variant:
- if variant.getId() == "empty_variant":
- variant_index = global_container_stack.getContainerIndex(variant)
- variant = self._createVariant(global_container_stack, variant_index)
- variant.setProperty("machine_heated_bed", "value", heated_bed)
- self.heatedBedChanged.emit()
+ # Make sure there is a definition_changes container to store the machine settings
+ definition_changes_container = global_container_stack.findContainer({"type": "definition_changes"})
+ if not definition_changes_container:
+ definition_changes_container = self._createDefinitionChangesContainer(global_container_stack)
- def _createVariant(self, global_container_stack, variant_index):
- # Create and switch to a variant to store the settings in
- new_variant = UM.Settings.InstanceContainer(global_container_stack.getName() + "_variant")
- new_variant.addMetaDataEntry("type", "variant")
- new_variant.setDefinition(global_container_stack.getBottom())
- UM.Settings.ContainerRegistry.getInstance().addContainer(new_variant)
- global_container_stack.replaceContainer(variant_index, new_variant)
- return new_variant
\ No newline at end of file
+ definition_changes_container.setProperty("machine_heated_bed", "value", heated_bed)
+ self.heatedBedChanged.emit()
+
+ def _createDefinitionChangesContainer(self, global_container_stack):
+ # Create a definition_changes container to store the settings in and add it to the stack
+ definition_changes_container = UM.Settings.InstanceContainer(global_container_stack.getName() + "_settings")
+ definition = global_container_stack.getBottom()
+ definition_changes_container.setDefinition(definition)
+ definition_changes_container.addMetaDataEntry("type", "definition_changes")
+
+ UM.Settings.ContainerRegistry.getInstance().addContainer(definition_changes_container)
+ # Insert definition_changes between the definition and the variant
+ global_container_stack.insertContainer(-1, definition_changes_container)
+
+ return definition_changes_container
diff --git a/plugins/VersionUpgrade/VersionUpgrade21to22/Profile.py b/plugins/VersionUpgrade/VersionUpgrade21to22/Profile.py
index d7d20db071..8be5a63151 100644
--- a/plugins/VersionUpgrade/VersionUpgrade21to22/Profile.py
+++ b/plugins/VersionUpgrade/VersionUpgrade21to22/Profile.py
@@ -5,6 +5,7 @@ import configparser #To read config files.
import io #To write config files to strings as if they were files.
import UM.VersionUpgrade
+from UM.Logger import Logger
## Creates a new profile instance by parsing a serialised profile in version 1
# of the file format.
diff --git a/plugins/XmlMaterialProfile/XmlMaterialProfile.py b/plugins/XmlMaterialProfile/XmlMaterialProfile.py
index 07acc5c37c..84a7d95a7c 100644
--- a/plugins/XmlMaterialProfile/XmlMaterialProfile.py
+++ b/plugins/XmlMaterialProfile/XmlMaterialProfile.py
@@ -165,7 +165,7 @@ class XmlMaterialProfile(UM.Settings.InstanceContainer):
machine_container_map = {}
machine_nozzle_map = {}
- all_containers = registry.findInstanceContainers(GUID = self.getMetaDataEntry("GUID"))
+ all_containers = registry.findInstanceContainers(GUID = self.getMetaDataEntry("GUID"), base_file = self._id)
for container in all_containers:
definition_id = container.getDefinition().id
if definition_id == "fdmprinter":
@@ -209,7 +209,17 @@ class XmlMaterialProfile(UM.Settings.InstanceContainer):
if not variant_containers:
continue
- builder.start("hotend", { "id": variant_containers[0].getName() })
+ builder.start("hotend", {"id": variant_containers[0].getName()})
+
+ # Compatible is a special case, as it's added as a meta data entry (instead of an instance).
+ compatible = hotend.getMetaDataEntry("compatible")
+ if compatible is not None:
+ builder.start("setting", {"key": "hardware compatible"})
+ if compatible:
+ builder.data("yes")
+ else:
+ builder.data("no")
+ builder.end("setting")
for instance in hotend.findInstances():
if container.getInstance(instance.definition.key) and container.getProperty(instance.definition.key, "value") == instance.value:
@@ -340,10 +350,22 @@ class XmlMaterialProfile(UM.Settings.InstanceContainer):
mapping[key] = element
first.append(element)
+ def clearData(self):
+ self._metadata = {}
+ self._name = ""
+ self._definition = None
+ self._instances = {}
+ self._read_only = False
+ self._dirty = False
+ self._path = ""
+
## Overridden from InstanceContainer
def deserialize(self, serialized):
data = ET.fromstring(serialized)
+ # Reset previous metadata
+ self.clearData() # Ensure any previous data is gone.
+
self.addMetaDataEntry("type", "material")
self.addMetaDataEntry("base_file", self.id)
@@ -403,7 +425,7 @@ class XmlMaterialProfile(UM.Settings.InstanceContainer):
for entry in settings:
key = entry.get("key")
if key in self.__material_property_setting_map:
- self.setProperty(self.__material_property_setting_map[key], "value", entry.text, self._definition)
+ self.setProperty(self.__material_property_setting_map[key], "value", entry.text)
global_setting_values[self.__material_property_setting_map[key]] = entry.text
elif key in self.__unmapped_settings:
if key == "hardware compatible":
@@ -445,7 +467,16 @@ class XmlMaterialProfile(UM.Settings.InstanceContainer):
definition = definitions[0]
if machine_compatibility:
- new_material = XmlMaterialProfile(self.id + "_" + machine_id)
+ new_material_id = self.id + "_" + machine_id
+
+ # It could be that we are overwriting, so check if the ID already exists.
+ materials = UM.Settings.ContainerRegistry.getInstance().findInstanceContainers(id=new_material_id)
+ if materials:
+ new_material = materials[0]
+ new_material.clearData()
+ else:
+ new_material = XmlMaterialProfile(new_material_id)
+
new_material.setName(self.getName())
new_material.setMetaData(copy.deepcopy(self.getMetaData()))
new_material.setDefinition(definition)
@@ -453,15 +484,14 @@ class XmlMaterialProfile(UM.Settings.InstanceContainer):
new_material.getMetaData()["compatible"] = machine_compatibility
for key, value in global_setting_values.items():
- new_material.setProperty(key, "value", value, definition)
+ new_material.setProperty(key, "value", value)
for key, value in machine_setting_values.items():
- new_material.setProperty(key, "value", value, definition)
+ new_material.setProperty(key, "value", value)
new_material._dirty = False
-
- UM.Settings.ContainerRegistry.getInstance().addContainer(new_material)
-
+ if not materials:
+ UM.Settings.ContainerRegistry.getInstance().addContainer(new_material)
hotends = machine.iterfind("./um:hotend", self.__namespaces)
for hotend in hotends:
@@ -491,7 +521,15 @@ class XmlMaterialProfile(UM.Settings.InstanceContainer):
else:
Logger.log("d", "Unsupported material setting %s", key)
- new_hotend_material = XmlMaterialProfile(self.id + "_" + machine_id + "_" + hotend_id.replace(" ", "_"))
+ # It could be that we are overwriting, so check if the ID already exists.
+ new_hotend_id = self.id + "_" + machine_id + "_" + hotend_id.replace(" ", "_")
+ materials = UM.Settings.ContainerRegistry.getInstance().findInstanceContainers(id=new_hotend_id)
+ if materials:
+ new_hotend_material = materials[0]
+ new_hotend_material.clearData()
+ else:
+ new_hotend_material = XmlMaterialProfile(new_hotend_id)
+
new_hotend_material.setName(self.getName())
new_hotend_material.setMetaData(copy.deepcopy(self.getMetaData()))
new_hotend_material.setDefinition(definition)
@@ -500,16 +538,17 @@ class XmlMaterialProfile(UM.Settings.InstanceContainer):
new_hotend_material.getMetaData()["compatible"] = hotend_compatibility
for key, value in global_setting_values.items():
- new_hotend_material.setProperty(key, "value", value, definition)
+ new_hotend_material.setProperty(key, "value", value)
for key, value in machine_setting_values.items():
- new_hotend_material.setProperty(key, "value", value, definition)
+ new_hotend_material.setProperty(key, "value", value)
for key, value in hotend_setting_values.items():
- new_hotend_material.setProperty(key, "value", value, definition)
+ new_hotend_material.setProperty(key, "value", value)
new_hotend_material._dirty = False
- UM.Settings.ContainerRegistry.getInstance().addContainer(new_hotend_material)
+ if not materials: # It was not added yet, do so now.
+ UM.Settings.ContainerRegistry.getInstance().addContainer(new_hotend_material)
def _addSettingElement(self, builder, instance):
try:
diff --git a/resources/definitions/fdmprinter.def.json b/resources/definitions/fdmprinter.def.json
index 5e41011963..2d77d62670 100644
--- a/resources/definitions/fdmprinter.def.json
+++ b/resources/definitions/fdmprinter.def.json
@@ -169,7 +169,7 @@
},
"machine_extruder_count":
{
- "label": "Number extruders",
+ "label": "Number of Extruders",
"description": "Number of extruder trains. An extruder train is the combination of a feeder, bowden tube, and nozzle.",
"default_value": 1,
"minimum_value": "1",
@@ -921,6 +921,17 @@
}
}
},
+ "fill_perimeter_gaps": {
+ "label": "Fill Gaps Between Walls",
+ "description": "Fills the gaps between walls where no walls fit.",
+ "type": "enum",
+ "options": {
+ "nowhere": "Nowhere",
+ "everywhere": "Everywhere"
+ },
+ "default_value": "everywhere",
+ "settable_per_mesh": true
+ },
"xy_offset":
{
"label": "Horizontal Expansion",
@@ -1179,6 +1190,34 @@
"settable_per_mesh": false,
"settable_per_extruder": true
},
+ "material_initial_print_temperature":
+ {
+ "label": "Initial Printing Temperature",
+ "description": "The minimal temperature while heating up to the Printing Temperature at which printing can already start.",
+ "unit": "°C",
+ "type": "float",
+ "value": "max(-273.15, material_print_temperature - 10)",
+ "minimum_value": "-273.15",
+ "minimum_value_warning": "material_standby_temperature",
+ "maximum_value_warning": "material_print_temperature",
+ "enabled": "machine_gcode_flavor != \"UltiGCode\"",
+ "settable_per_mesh": false,
+ "settable_per_extruder": true
+ },
+ "material_final_print_temperature":
+ {
+ "label": "Final Printing Temperature",
+ "description": "The temperature to which to already start cooling down just before the end of printing.",
+ "unit": "°C",
+ "type": "float",
+ "value": "max(-273.15, material_print_temperature - 15)",
+ "minimum_value": "-273.15",
+ "minimum_value_warning": "material_standby_temperature",
+ "maximum_value_warning": "material_print_temperature",
+ "enabled": "machine_gcode_flavor != \"UltiGCode\"",
+ "settable_per_mesh": false,
+ "settable_per_extruder": true
+ },
"material_flow_temp_graph":
{
"label": "Flow Temperature Graph",
@@ -1200,6 +1239,7 @@
"default_value": 0.5,
"minimum_value": "0",
"maximum_value_warning": "10.0",
+ "maximum_value": "machine_nozzle_heat_up_speed",
"enabled": "False",
"comments": "old enabled function: material_flow_dependent_temperature or machine_extruder_count > 1",
"settable_per_mesh": false,
@@ -1656,6 +1696,7 @@
"unit": "mm/s",
"type": "float",
"default_value": 30,
+ "value": "speed_print * 30 / 60",
"minimum_value": "0.1",
"maximum_value": "math.sqrt(machine_max_feedrate_x ** 2 + machine_max_feedrate_y ** 2)",
"maximum_value_warning": "300",
@@ -2246,7 +2287,7 @@
"retraction_combing":
{
"label": "Combing Mode",
- "description": "Combing keeps the nozzle within already printed areas when traveling. This results in slightly longer travel moves but reduces the need for retractions. If combing is off, the material will retract and the nozzle moves in a straight line to the next point. It is also possible to avoid combing over top/bottom skin areas by combing within the infill only. It is also possible to avoid combing over top/bottom skin areas by combing within the infill only.",
+ "description": "Combing keeps the nozzle within already printed areas when traveling. This results in slightly longer travel moves but reduces the need for retractions. If combing is off, the material will retract and the nozzle moves in a straight line to the next point. It is also possible to avoid combing over top/bottom skin areas by combing within the infill only.",
"type": "enum",
"options":
{
@@ -2256,12 +2297,12 @@
},
"default_value": "all",
"resolve": "'noskin' if 'noskin' in extruderValues('retraction_combing') else ('all' if 'all' in extruderValues('retraction_combing') else 'off')",
- "settable_per_mesh": true,
+ "settable_per_mesh": false,
"settable_per_extruder": false
},
"travel_avoid_other_parts":
{
- "label": "Avoid Printed Parts when Traveling",
+ "label": "Avoid Printed Parts When Traveling",
"description": "The nozzle avoids already printed parts when traveling. This option is only available when combing is enabled.",
"type": "bool",
"default_value": true,
@@ -2286,8 +2327,8 @@
},
"start_layers_at_same_position":
{
- "label": "Start Layers Near Same Point",
- "description": "Start printing the objects in each layer near the same point, so that we don't start a new layer with printing the piece which the previous layer ended with. This makes for better overhangs and small parts, but increases printing time.",
+ "label": "Start Layers with the Same Part",
+ "description": "In each layer start with printing the object near the same point, so that we don't start a new layer with printing the piece which the previous layer ended with. This makes for better overhangs and small parts, but increases printing time.",
"type": "bool",
"default_value": false,
"settable_per_mesh": false,
@@ -2297,7 +2338,7 @@
"layer_start_x":
{
"label": "Layer Start X",
- "description": "The X coordinate of the position near where to start printing objects each layer.",
+ "description": "The X coordinate of the position near where to find the part to start printing each layer.",
"unit": "mm",
"type": "float",
"default_value": 0.0,
@@ -2310,7 +2351,7 @@
"layer_start_y":
{
"label": "Layer Start Y",
- "description": "The Y coordinate of the position near where to start printing objects each layer.",
+ "description": "The Y coordinate of the position near where to find the part to start printing each layer.",
"unit": "mm",
"type": "float",
"default_value": 0.0,
@@ -2321,7 +2362,7 @@
"settable_per_meshgroup": true
},
"retraction_hop_enabled": {
- "label": "Z Hop when Retracted",
+ "label": "Z Hop When Retracted",
"description": "Whenever a retraction is done, the build plate is lowered to create clearance between the nozzle and the print. It prevents the nozzle from hitting the print during travel moves, reducing the chance to knock the print from the build plate.",
"type": "bool",
"default_value": false,
@@ -2393,6 +2434,20 @@
"settable_per_extruder": true,
"children":
{
+ "cool_fan_speed_0":
+ {
+ "label": "Initial Fan Speed",
+ "description": "The speed at which the fans spin at the start of the print. In subsequent layers the fan speed is gradually increased up to the layer corresponding to Regular Fan Speed at Height.",
+ "unit": "%",
+ "type": "float",
+ "minimum_value": "0",
+ "maximum_value": "100",
+ "value": "cool_fan_speed",
+ "default_value": 100,
+ "enabled": "cool_fan_enabled",
+ "settable_per_mesh": false,
+ "settable_per_extruder": true
+ },
"cool_fan_speed_min":
{
"label": "Regular Fan Speed",
@@ -2438,7 +2493,7 @@
"cool_fan_full_at_height":
{
"label": "Regular Fan Speed at Height",
- "description": "The height at which the fans spin on regular fan speed. At the layers below the fan speed gradually increases from zero to regular fan speed.",
+ "description": "The height at which the fans spin on regular fan speed. At the layers below the fan speed gradually increases from Initial Fan Speed to Regular Fan Speed.",
"unit": "mm",
"type": "float",
"default_value": 0.5,
@@ -2871,7 +2926,7 @@
"type": "float",
"default_value": 0.4,
"minimum_value": "0",
- "minimum_value_warning": "support_interface_line_width",
+ "minimum_value_warning": "support_interface_line_width - 0.0001",
"value": "0 if support_interface_density == 0 else (support_interface_line_width * 100) / support_interface_density * (2 if support_interface_pattern == 'grid' else (3 if support_interface_pattern == 'triangles' else 1))",
"limit_to_extruder": "support_interface_extruder_nr",
"enabled": "extruderValue(support_interface_extruder_nr, 'support_interface_enable') and support_enable",
@@ -2997,7 +3052,8 @@
{
"skirt": "Skirt",
"brim": "Brim",
- "raft": "Raft"
+ "raft": "Raft",
+ "none": "None"
},
"default_value": "brim",
"resolve": "'raft' if 'raft' in extruderValues('adhesion_type') else ('brim' if 'brim' in extruderValues('adhesion_type') else 'skirt')",
@@ -3010,7 +3066,7 @@
"description": "The extruder train to use for printing the skirt/brim/raft. This is used in multi-extrusion.",
"type": "extruder",
"default_value": "0",
- "enabled": "machine_extruder_count > 1",
+ "enabled": "machine_extruder_count > 1 and resolveOrValue('adhesion_type') != 'none'",
"settable_per_mesh": false,
"settable_per_extruder": false
},
@@ -3582,7 +3638,6 @@
"unit": "mm",
"enabled": "resolveOrValue('prime_tower_enable')",
"default_value": 15,
- "value": "15 if resolveOrValue('prime_tower_enable') else 0",
"resolve": "max(extruderValues('prime_tower_size'))",
"minimum_value": "0",
"maximum_value": "min(0.5 * machine_width, 0.5 * machine_depth)",
@@ -3591,6 +3646,38 @@
"settable_per_mesh": false,
"settable_per_extruder": false
},
+ "prime_tower_min_volume":
+ {
+ "label": "Prime Tower Minimum Volume",
+ "description": "The minimum volume for each layer of the prime tower in order to purge enough material.",
+ "unit": "mm³",
+ "type": "float",
+ "default_value": 10,
+ "minimum_value": "0",
+ "maximum_value_warning": "resolveOrValue('prime_tower_size') ** 2 * resolveOrValue('layer_height')",
+ "enabled": "resolveOrValue('prime_tower_enable')",
+ "settable_per_mesh": false,
+ "settable_per_extruder": true,
+ "children":
+ {
+ "prime_tower_wall_thickness":
+ {
+ "label": "Prime Tower Thickness",
+ "description": "The thickness of the hollow prime tower. A thickness larger than half the Prime Tower Minimum Volume will result in a dense prime tower.",
+ "unit": "mm",
+ "type": "float",
+ "default_value": 2,
+ "value": "max(2 * min(extruderValues('prime_tower_line_width')), 0.5 * (resolveOrValue('prime_tower_size') - math.sqrt(resolveOrValue('prime_tower_size') ** 2 - max(extruderValues('prime_tower_min_volume')) / resolveOrValue('layer_height'))))",
+ "resolve": "max(extruderValues('prime_tower_wall_thickness'))",
+ "minimum_value": "0.001",
+ "minimum_value_warning": "2 * min(extruderValues('prime_tower_line_width'))",
+ "maximum_value_warning": "resolveOrValue('prime_tower_size') / 2",
+ "enabled": "resolveOrValue('prime_tower_enable')",
+ "settable_per_mesh": false,
+ "settable_per_extruder": false
+ }
+ }
+ },
"prime_tower_position_x":
{
"label": "Prime Tower X Position",
@@ -3637,14 +3724,23 @@
},
"prime_tower_wipe_enabled":
{
- "label": "Wipe Nozzle on Prime Tower",
+ "label": "Wipe Inactive Nozzle on Prime Tower",
"description": "After printing the prime tower with one nozzle, wipe the oozed material from the other nozzle off on the prime tower.",
"type": "bool",
"enabled": "resolveOrValue('prime_tower_enable')",
"default_value": true,
- "resolve": "any(extruderValues('prime_tower_wipe_enabled'))",
"settable_per_mesh": false,
- "settable_per_extruder": false
+ "settable_per_extruder": true
+ },
+ "dual_pre_wipe":
+ {
+ "label": "Wipe Nozzle After Switch",
+ "description": "After switching extruder, wipe the oozed material off of the nozzle on the first thing printed. This performs a safe slow wipe move at a place where the oozed material causes least harm to the surface quality of your print.",
+ "type": "bool",
+ "enabled": "resolveOrValue('prime_tower_enable')",
+ "default_value": true,
+ "settable_per_mesh": false,
+ "settable_per_extruder": true
},
"multiple_mesh_overlap":
{
@@ -3708,7 +3804,7 @@
"meshfix_union_all":
{
"label": "Union Overlapping Volumes",
- "description": "Ignore the internal geometry arising from overlapping volumes and print the volumes as one. This may cause internal cavities to disappear.",
+ "description": "Ignore the internal geometry arising from overlapping volumes within a mesh and print the volumes as one. This may cause unintended internal cavities to disappear.",
"type": "bool",
"default_value": true,
"settable_per_mesh": true
@@ -3740,13 +3836,24 @@
"carve_multiple_volumes":
{
"label": "Remove Mesh Intersection",
- "description": "Remove areas where multiple objects are overlapping with each other. This may be used if merged dual material objects overlap with each other.",
+ "description": "Remove areas where multiple meshes are overlapping with each other. This may be used if merged dual material objects overlap with each other.",
"type": "bool",
"default_value": true,
"value": "machine_extruder_count > 1",
"settable_per_mesh": false,
"settable_per_extruder": false,
"settable_per_meshgroup": true
+ },
+ "alternate_carve_order":
+ {
+ "label": "Alternate Mesh Removal",
+ "description": "Switch to which mesh intersecting volumes will belong with every layer, so that the overlapping meshes become interwoven. Turning this setting off will cause one of the meshes to obtain all of the volume in the overlap, while it is removed from the other meshes.",
+ "type": "bool",
+ "default_value": true,
+ "enabled": "carve_multiple_volumes",
+ "settable_per_mesh": false,
+ "settable_per_extruder": false,
+ "settable_per_meshgroup": true
}
}
},
@@ -3769,6 +3876,7 @@
"one_at_a_time": "One at a Time"
},
"default_value": "all_at_once",
+ "enabled": "machine_extruder_count == 1",
"settable_per_mesh": false,
"settable_per_extruder": false,
"settable_per_meshgroup": false
@@ -3798,6 +3906,28 @@
"settable_per_meshgroup": false,
"settable_globally": false
},
+ "support_mesh":
+ {
+ "label": "Support Mesh",
+ "description": "Use this mesh to specify support areas. This can be used to generate support structure.",
+ "type": "bool",
+ "default_value": false,
+ "settable_per_mesh": true,
+ "settable_per_extruder": false,
+ "settable_per_meshgroup": false,
+ "settable_globally": false
+ },
+ "anti_overhang_mesh":
+ {
+ "label": "Anti Overhang Mesh",
+ "description": "Use this mesh to specify where no part of the model should be detected as overhang. This can be used to remove unwanted support structure.",
+ "type": "bool",
+ "default_value": false,
+ "settable_per_mesh": true,
+ "settable_per_extruder": false,
+ "settable_per_meshgroup": false,
+ "settable_globally": false
+ },
"magic_mesh_surface_mode":
{
"label": "Surface Mode",
diff --git a/resources/definitions/jellybox.def.json b/resources/definitions/jellybox.def.json
new file mode 100644
index 0000000000..fa3cb35cf7
--- /dev/null
+++ b/resources/definitions/jellybox.def.json
@@ -0,0 +1,35 @@
+{
+ "id": "jellybox",
+ "version": 2,
+ "name": "JellyBOX",
+ "inherits": "fdmprinter",
+ "metadata": {
+ "visible": true,
+ "author": "IMADE3D",
+ "manufacturer": "IMADE3D",
+ "category": "Other",
+ "platform": "jellybox_platform.stl",
+ "platform_offset": [ 0, -0.3, 0],
+ "file_formats": "text/x-gcode",
+ "has_materials": true,
+ "has_machine_materials": true
+ },
+
+ "overrides": {
+ "machine_name": { "default_value": "IMADE3D JellyBOX" },
+ "machine_width": { "default_value": 170 },
+ "machine_height": { "default_value": 145 },
+ "machine_depth": { "default_value": 160 },
+ "machine_nozzle_size": { "default_value": 0.4 },
+ "material_diameter": { "default_value": 1.75 },
+ "machine_heated_bed": { "default_value": true },
+ "machine_center_is_zero": { "default_value": false },
+ "machine_gcode_flavor": { "default_value": "RepRap (Marlin/Sprinter)" },
+ "machine_start_gcode": {
+ "default_value": ";---------------------------------------\n; ; ; Jellybox Start Script Begin ; ; ;\n;_______________________________________\n; M92 E140 ;optionally adjust steps per mm for your filament\n\n; Print Settings Summary\n; (overwriting these values will NOT change your printer's behavior)\n; sliced for: {machine_name}\n; nozzle diameter: {machine_nozzle_size}\n; filament diameter: {material_diameter}\n; layer height: {layer_height}\n; 1st layer height: {layer_height_0}\n; line width: {line_width}\n; wall thickness: {wall_thickness}\n; infill density: {infill_sparse_density}\n; infill pattern: {infill_pattern}\n; print temperature: {material_print_temperature}\n; heated bed temperature: {material_bed_temperature}\n; regular fan speed: {cool_fan_speed_min}\n; max fan speed: {cool_fan_speed_max}\n; support? {support_enable}\n; spiralized? {magic_spiralize}\n\nM117 Preparing ;write Preparing\nM140 S{material_bed_temperature} ;set bed temperature and move on\nM104 S{material_print_temperature} ;set extruder temperature and move on\nM206 X10.0 Y0.0 ;set x homing offset for default bed leveling\nG21 ;metric values\nG90 ;absolute positioning\nM107 ;start with the fan off\nM82 ;set extruder to absolute mode\nG28 ;home all axes\nM203 Z5 ;slow Z speed down for greater accuracy when probing\nG29 ;auto bed leveling procedure\nM203 Z7 ;pick up z speed again for printing\nM190 S{material_bed_temperature} ;wait for the bed to reach desired temperature\nM109 S{material_print_temperature} ;wait for the extruder to reach desired temperature\nG92 E0 ;reset the extruder position\nG1 F200 E5 ;extrude 5mm of feed stock\nG92 E0 ;reset the extruder position again\nM117 Print starting ;write Print starting\n;---------------------------------------------\n; ; ; Jellybox Printer Start Script End ; ; ;\n;_____________________________________________"
+ },
+ "machine_end_gcode": {
+ "default_value": "\n;---------------------------------\n;;; Jellybox End Script Begin ;;;\n;_________________________________\nM117 Finishing Up ;write Finishing Up\n\nM104 S0 ;extruder heater off\nM140 S0 ;bed heater off (if you have it)\nG91 ;relative positioning\nG1 E-1 F300 ;retract the filament a bit before lifting the nozzle, to release some of the pressure\nG1 Z+0.5 E-5 X-20 Y-20 F9000 ;move Z up a bit and retract filament even more\nG90 ;absolute positioning\nG28 X ;home x, so the head is out of the way\nG1 Y100 ;move Y forward, so the print is more accessible\nM84 ;steppers off\n\nM117 Print finished ;write Print finished\n;---------------------------------------\n;;; Jellybox End Script End ;;;\n;_______________________________________"
+ }
+ }
+}
diff --git a/resources/definitions/ultimaker3.def.json b/resources/definitions/ultimaker3.def.json
index fe5a9153e5..ae0966242d 100644
--- a/resources/definitions/ultimaker3.def.json
+++ b/resources/definitions/ultimaker3.def.json
@@ -60,9 +60,7 @@
[[92.8, -53.4], [92.8, -97.5], [116.5, -97.5], [116.5, -53.4]],
[[73.8, 107.5], [73.8, 100.5], [116.5, 100.5], [116.5, 107.5]],
[[74.6, 107.5], [74.6, 100.5], [116.5, 100.5], [116.5, 107.5]],
- [[74.9, -97.5], [74.9, -107.5], [116.5, -107.5], [116.5, -97.5]]
- ]},
- "nozzle_disallowed_areas": { "default_value": [
+ [[74.9, -97.5], [74.9, -107.5], [116.5, -107.5], [116.5, -97.5]],
[[-116.5, -103.5], [-116.5, -107.5], [-100.9, -107.5], [-100.9, -103.5]],
[[-116.5, 105.8], [-96.9, 105.8], [-96.9, 107.5], [-116.5, 107.5]]
]},
@@ -72,8 +70,7 @@
"machine_end_gcode": { "default_value": "" },
"prime_tower_position_x": { "default_value": 175 },
"prime_tower_position_y": { "default_value": 179 },
-
- "print_sequence": {"enabled": false},
+ "prime_tower_wipe_enabled": { "default_value": false },
"acceleration_enabled": { "value": "True" },
"acceleration_layer_0": { "value": "acceleration_topbottom" },
@@ -103,8 +100,8 @@
"jerk_wall": { "value": "math.ceil(jerk_print * 10 / 25)" },
"jerk_wall_0": { "value": "math.ceil(jerk_wall * 5 / 10)" },
"layer_height_0": { "value": "round(machine_nozzle_size / 1.5, 2)" },
- "layer_start_x": { "value": "sum(extruderValues('extruder_prime_pos_x')) / len(extruderValues('extruder_prime_pos_x'))" },
- "layer_start_y": { "value": "sum(extruderValues('extruder_prime_pos_y')) / len(extruderValues('extruder_prime_pos_y'))" },
+ "layer_start_x": { "value": "sum(extruderValues('machine_extruder_start_pos_x')) / len(extruderValues('machine_extruder_start_pos_x'))" },
+ "layer_start_y": { "value": "sum(extruderValues('machine_extruder_start_pos_y')) / len(extruderValues('machine_extruder_start_pos_y'))" },
"line_width": { "value": "machine_nozzle_size * 0.875" },
"machine_min_cool_heat_time_window": { "value": "15" },
"material_print_temperature": { "value": "200" },
@@ -112,15 +109,12 @@
"multiple_mesh_overlap": { "value": "0" },
"prime_tower_enable": { "value": "True" },
"raft_airgap": { "value": "0" },
- "raft_base_speed": { "value": "20" },
"raft_base_thickness": { "value": "0.3" },
"raft_interface_line_spacing": { "value": "0.5" },
"raft_interface_line_width": { "value": "0.5" },
- "raft_interface_speed": { "value": "20" },
"raft_interface_thickness": { "value": "0.2" },
"raft_jerk": { "value": "jerk_layer_0" },
"raft_margin": { "value": "10" },
- "raft_speed": { "value": "25" },
"raft_surface_layers": { "value": "1" },
"retraction_amount": { "value": "2" },
"retraction_count_max": { "value": "10" },
@@ -131,7 +125,7 @@
"retraction_min_travel": { "value": "5" },
"retraction_prime_speed": { "value": "15" },
"skin_overlap": { "value": "10" },
- "speed_layer_0": { "value": "20" },
+ "speed_layer_0": { "value": "speed_print * 30 / 70" },
"speed_prime_tower": { "value": "speed_topbottom" },
"speed_print": { "value": "35" },
"speed_support": { "value": "speed_wall_0" },
diff --git a/resources/definitions/ultimaker_original_dual.def.json b/resources/definitions/ultimaker_original_dual.def.json
index 48d2436b92..d133a3853f 100644
--- a/resources/definitions/ultimaker_original_dual.def.json
+++ b/resources/definitions/ultimaker_original_dual.def.json
@@ -72,14 +72,11 @@
"machine_extruder_count": {
"default_value": 2
},
- "print_sequence": {
- "enabled": false
- },
"prime_tower_position_x": {
- "default_value": 185
+ "default_value": 195
},
"prime_tower_position_y": {
- "default_value": 160
+ "default_value": 149
}
}
}
diff --git a/resources/i18n/de/cura.po b/resources/i18n/de/cura.po
index b015b2e43b..985764f6ff 100644
--- a/resources/i18n/de/cura.po
+++ b/resources/i18n/de/cura.po
@@ -1913,7 +1913,7 @@ msgstr "&Beenden"
#: /home/ruben/Projects/Cura/resources/qml/Actions.qml:97
msgctxt "@action:inmenu"
msgid "Configure Cura..."
-msgstr "Cura wird konfiguriert..."
+msgstr "Cura konfigurieren..."
#: /home/ruben/Projects/Cura/resources/qml/Actions.qml:104
msgctxt "@action:inmenu menubar:printer"
diff --git a/resources/i18n/nl/cura.po b/resources/i18n/nl/cura.po
index 2c8c83df7a..64e140474c 100644
--- a/resources/i18n/nl/cura.po
+++ b/resources/i18n/nl/cura.po
@@ -2629,3 +2629,8 @@ msgstr "De configuratie van de printer in Cura laden"
msgctxt "@action:button"
msgid "Activate Configuration"
msgstr "Configuratie Activeren"
+
+#: /home/ruben/Projects/Cura/resources/qml/Preferences/MaterialView.qml:25
+msgctxt "@title"
+msgid "Information"
+msgstr "Informatie"
diff --git a/resources/meshes/jellybox_platform.stl b/resources/meshes/jellybox_platform.stl
new file mode 100644
index 0000000000..900c267538
Binary files /dev/null and b/resources/meshes/jellybox_platform.stl differ
diff --git a/resources/qml/Actions.qml b/resources/qml/Actions.qml
index 043552d768..bb4e28eae7 100644
--- a/resources/qml/Actions.qml
+++ b/resources/qml/Actions.qml
@@ -11,6 +11,7 @@ import Cura 1.0 as Cura
Item
{
property alias open: openAction;
+ property alias loadWorkspace: loadWorkspaceAction;
property alias quit: quitAction;
property alias undo: undoAction;
@@ -122,7 +123,7 @@ Item
{
id: updateProfileAction;
enabled: !Cura.MachineManager.stacksHaveErrors && Cura.MachineManager.hasUserSettings && !Cura.MachineManager.isReadOnly(Cura.MachineManager.activeQualityId)
- text: catalog.i18nc("@action:inmenu menubar:profile","&Update profile with current settings");
+ text: catalog.i18nc("@action:inmenu menubar:profile","&Update profile with current settings/overrides");
onTriggered: Cura.ContainerManager.updateQualityChanges();
}
@@ -142,7 +143,7 @@ Item
{
id: addProfileAction;
enabled: !Cura.MachineManager.stacksHaveErrors && Cura.MachineManager.hasUserSettings
- text: catalog.i18nc("@action:inmenu menubar:profile","&Create profile from current settings...");
+ text: catalog.i18nc("@action:inmenu menubar:profile","&Create profile from current settings/overrides...");
}
Action
@@ -286,6 +287,12 @@ Item
shortcut: StandardKey.Open;
}
+ Action
+ {
+ id: loadWorkspaceAction
+ text: catalog.i18nc("@action:inmenu menubar:file","&Open Workspace...");
+ }
+
Action
{
id: showEngineLogAction;
diff --git a/resources/qml/Cura.qml b/resources/qml/Cura.qml
index ab4d7bfd06..85be3342e9 100644
--- a/resources/qml/Cura.qml
+++ b/resources/qml/Cura.qml
@@ -7,7 +7,7 @@ import QtQuick.Controls.Styles 1.1
import QtQuick.Layouts 1.1
import QtQuick.Dialogs 1.1
-import UM 1.2 as UM
+import UM 1.3 as UM
import Cura 1.0 as Cura
import "Menus"
@@ -67,9 +67,14 @@ UM.MainWindow
id: fileMenu
title: catalog.i18nc("@title:menu menubar:toplevel","&File");
- MenuItem {
+ MenuItem
+ {
action: Cura.Actions.open;
}
+ MenuItem
+ {
+ action: Cura.Actions.loadWorkspace
+ }
RecentFilesMenu { }
@@ -102,6 +107,12 @@ UM.MainWindow
onObjectRemoved: saveAllMenu.removeItem(object)
}
}
+ MenuItem
+ {
+ id: saveWorkspaceMenu
+ text: catalog.i18nc("@title:menu menubar:file","Save Workspace")
+ onTriggered: UM.OutputDeviceManager.requestWriteToDevice("local_file", PrintInformation.jobName, { "filter_by_machine": false, "file_type": "workspace" });
+ }
MenuItem { action: Cura.Actions.reloadAll; }
@@ -723,6 +734,38 @@ UM.MainWindow
onTriggered: openDialog.open()
}
+ FileDialog
+ {
+ id: openWorkspaceDialog;
+
+ //: File open dialog title
+ title: catalog.i18nc("@title:window","Open workspace")
+ modality: UM.Application.platform == "linux" ? Qt.NonModal : Qt.WindowModal;
+ selectMultiple: false
+ nameFilters: UM.WorkspaceFileHandler.supportedReadFileTypes;
+ folder: CuraApplication.getDefaultPath("dialog_load_path")
+ onAccepted:
+ {
+ //Because several implementations of the file dialog only update the folder
+ //when it is explicitly set.
+ var f = folder;
+ folder = f;
+
+ CuraApplication.setDefaultPath("dialog_load_path", folder);
+
+ for(var i in fileUrls)
+ {
+ UM.WorkspaceFileHandler.readLocalFile(fileUrls[i])
+ }
+ }
+ }
+
+ Connections
+ {
+ target: Cura.Actions.loadWorkspace
+ onTriggered:openWorkspaceDialog.open()
+ }
+
EngineLog
{
id: engineLog;
diff --git a/resources/qml/MultiplyObjectOptions.qml b/resources/qml/MultiplyObjectOptions.qml
index 7756ab074b..4b22a96644 100644
--- a/resources/qml/MultiplyObjectOptions.qml
+++ b/resources/qml/MultiplyObjectOptions.qml
@@ -19,7 +19,7 @@ UM.Dialog
width: minimumWidth
height: minimumHeight
- property int objectId: 0;
+ property var objectId: 0;
onAccepted: Printer.multiplyObject(base.objectId, parseInt(copiesField.text))
property variant catalog: UM.I18nCatalog { name: "cura" }
diff --git a/resources/qml/Preferences/ProfilesPage.qml b/resources/qml/Preferences/ProfilesPage.qml
index b1f06af3a9..521145f872 100644
--- a/resources/qml/Preferences/ProfilesPage.qml
+++ b/resources/qml/Preferences/ProfilesPage.qml
@@ -162,7 +162,7 @@ UM.ManagementPage
Button
{
text: {
- return catalog.i18nc("@action:button", "Update profile with current settings");
+ return catalog.i18nc("@action:button", "Update profile with current settings/overrides");
}
enabled: Cura.MachineManager.hasUserSettings && !Cura.MachineManager.isReadOnly(Cura.MachineManager.activeQualityId)
onClicked: Cura.ContainerManager.updateQualityChanges()
@@ -187,7 +187,7 @@ UM.ManagementPage
Label {
id: defaultsMessage
visible: false
- text: catalog.i18nc("@action:label", "This profile uses the defaults specified by the printer, so it has no settings in the list below.")
+ text: catalog.i18nc("@action:label", "This profile uses the defaults specified by the printer, so it has no settings/overrides in the list below.")
wrapMode: Text.WordWrap
width: parent.width
}
diff --git a/resources/qml/Settings/SettingCheckBox.qml b/resources/qml/Settings/SettingCheckBox.qml
index 1fcd24ccf6..97a72a026e 100644
--- a/resources/qml/Settings/SettingCheckBox.qml
+++ b/resources/qml/Settings/SettingCheckBox.qml
@@ -29,11 +29,11 @@ SettingItem
// 4: variant
// 5: machine
var value;
- if ((propertyProvider.properties.resolve != "None") && (stackLevel != 0) && (stackLevel != 1)) {
+ if ((base.resolve != "None") && (stackLevel != 0) && (stackLevel != 1)) {
// We have a resolve function. Indicates that the setting is not settable per extruder and that
// we have to choose between the resolved value (default) and the global value
// (if user has explicitly set this).
- value = propertyProvider.properties.resolve;
+ value = base.resolve;
} else {
value = propertyProvider.properties.value;
}
diff --git a/resources/qml/Settings/SettingComboBox.qml b/resources/qml/Settings/SettingComboBox.qml
index c4ca637506..dfa070667a 100644
--- a/resources/qml/Settings/SettingComboBox.qml
+++ b/resources/qml/Settings/SettingComboBox.qml
@@ -96,11 +96,11 @@ SettingItem
{
// FIXME this needs to go away once 'resolve' is combined with 'value' in our data model.
var value;
- if ((propertyProvider.properties.resolve != "None") && (base.stackLevel != 0) && (base.stackLevel != 1)) {
+ if ((base.resolve != "None") && (base.stackLevel != 0) && (base.stackLevel != 1)) {
// We have a resolve function. Indicates that the setting is not settable per extruder and that
// we have to choose between the resolved value (default) and the global value
// (if user has explicitly set this).
- value = propertyProvider.properties.resolve;
+ value = base.resolve;
} else {
value = propertyProvider.properties.value;
}
diff --git a/resources/qml/Settings/SettingExtruder.qml b/resources/qml/Settings/SettingExtruder.qml
index 82d7def5ce..cbb717ac9b 100644
--- a/resources/qml/Settings/SettingExtruder.qml
+++ b/resources/qml/Settings/SettingExtruder.qml
@@ -30,6 +30,7 @@ SettingItem
textRole: "name"
anchors.fill: parent
+ onCurrentIndexChanged: updateCurrentColor();
MouseArea
{
@@ -115,12 +116,37 @@ SettingItem
propertyProvider.setPropertyValue("value", extruders_model.getItem(index).index);
control.color = extruders_model.getItem(index).color;
}
+
onModelChanged: updateCurrentIndex();
- Connections
+ Binding
{
- target: propertyProvider
- onPropertiesChanged: control.updateCurrentIndex();
+ target: control
+ property: "currentIndex"
+ value:
+ {
+ for(var i = 0; i < extruders_model.rowCount(); ++i)
+ {
+ if(extruders_model.getItem(i).index == propertyProvider.properties.value)
+ {
+ return i;
+ }
+ }
+ return -1;
+ }
+ }
+
+ // In some cases we want to update the current color without updating the currentIndex, so it's a seperate function.
+ function updateCurrentColor()
+ {
+ for(var i = 0; i < extruders_model.rowCount(); ++i)
+ {
+ if(extruders_model.getItem(i).index == propertyProvider.properties.value)
+ {
+ control.color = extruders_model.getItem(i).color;
+ return;
+ }
+ }
}
function updateCurrentIndex()
@@ -130,7 +156,6 @@ SettingItem
if(extruders_model.getItem(i).index == propertyProvider.properties.value)
{
control.currentIndex = i;
- control.color = extruders_model.getItem(i).color;
return;
}
}
diff --git a/resources/qml/Settings/SettingItem.qml b/resources/qml/Settings/SettingItem.qml
index 7fa2856e27..7a1f99961b 100644
--- a/resources/qml/Settings/SettingItem.qml
+++ b/resources/qml/Settings/SettingItem.qml
@@ -27,7 +27,8 @@ Item {
// Create properties to put property provider stuff in (bindings break in qt 5.5.1 otherwise)
property var state: propertyProvider.properties.state
- property var resolve: propertyProvider.properties.resolve
+ // There is no resolve property if there is only one stack.
+ property var resolve: Cura.MachineManager.activeStackId != Cura.MachineManager.activeMachineId ? propertyProvider.properties.resolve : "None"
property var stackLevels: propertyProvider.stackLevels
property var stackLevel: stackLevels[0]
@@ -117,6 +118,7 @@ Item {
elide: Text.ElideMiddle;
color: UM.Theme.getColor("setting_control_text");
+ opacity: (definition.visible) ? 1 : 0.5
// emphasize the setting if it has a value in the user or quality profile
font: base.doQualityUserSettingEmphasis && base.stackLevel != undefined && base.stackLevel <= 1 ? UM.Theme.getFont("default_italic") : UM.Theme.getFont("default")
}
@@ -208,14 +210,26 @@ Item {
// But this will cause the binding to be re-evaluated when the enabled property changes.
return false;
}
+
+ // There are no settings with any warning.
if(Cura.SettingInheritanceManager.settingsWithInheritanceWarning.length == 0)
{
return false;
}
+
+ // This setting has a resolve value, so an inheritance warning doesn't do anything.
+ if(resolve != "None")
+ {
+ return false
+ }
+
+ // If the setting does not have a limit_to_extruder property (or is -1), use the active stack.
if(globalPropertyProvider.properties.limit_to_extruder == null || globalPropertyProvider.properties.limit_to_extruder == -1)
{
return Cura.SettingInheritanceManager.settingsWithInheritanceWarning.indexOf(definition.key) >= 0;
}
+
+ // Setting does have a limit_to_extruder property, so use that one instead.
return Cura.SettingInheritanceManager.getOverridesForExtruder(definition.key, globalPropertyProvider.properties.limit_to_extruder).indexOf(definition.key) >= 0;
}
@@ -226,7 +240,7 @@ Item {
focus = true;
// Get the most shallow function value (eg not a number) that we can find.
- var last_entry = propertyProvider.stackLevels[propertyProvider.stackLevels.length]
+ var last_entry = propertyProvider.stackLevels[propertyProvider.stackLevels.length - 1]
for (var i = 1; i < base.stackLevels.length; i++)
{
var has_setting_function = typeof(propertyProvider.getPropertyValue("value", base.stackLevels[i])) == "object";
diff --git a/resources/qml/Settings/SettingTextField.qml b/resources/qml/Settings/SettingTextField.qml
index d02c8854b4..d89f540aa3 100644
--- a/resources/qml/Settings/SettingTextField.qml
+++ b/resources/qml/Settings/SettingTextField.qml
@@ -114,11 +114,11 @@ SettingItem
// 3: material -> user changed material in materialspage
// 4: variant
// 5: machine
- if ((propertyProvider.properties.resolve != "None" && propertyProvider.properties.resolve) && (stackLevel != 0) && (stackLevel != 1)) {
+ if ((base.resolve != "None" && base.resolve) && (stackLevel != 0) && (stackLevel != 1)) {
// We have a resolve function. Indicates that the setting is not settable per extruder and that
// we have to choose between the resolved value (default) and the global value
// (if user has explicitly set this).
- return propertyProvider.properties.resolve;
+ return base.resolve;
} else {
return propertyProvider.properties.value;
}
diff --git a/resources/qml/Settings/SettingView.qml b/resources/qml/Settings/SettingView.qml
index c47abf3ee2..5f20f92b20 100644
--- a/resources/qml/Settings/SettingView.qml
+++ b/resources/qml/Settings/SettingView.qml
@@ -9,229 +9,405 @@ import QtQuick.Layouts 1.1
import UM 1.2 as UM
import Cura 1.0 as Cura
-ScrollView
+Item
{
id: base;
- style: UM.Theme.styles.scrollview;
- flickableItem.flickableDirection: Flickable.VerticalFlick;
-
property Action configureSettings;
+ property bool findingSettings;
signal showTooltip(Item item, point location, string text);
signal hideTooltip();
- ListView
+ function toggleFilterField()
{
- id: contents
- spacing: UM.Theme.getSize("default_lining").height;
- cacheBuffer: 1000000; // Set a large cache to effectively just cache every list item.
-
- model: UM.SettingDefinitionsModel {
- id: definitionsModel;
- containerId: Cura.MachineManager.activeDefinitionId
- visibilityHandler: UM.SettingPreferenceVisibilityHandler { }
- exclude: ["machine_settings", "command_line_settings", "infill_mesh", "infill_mesh_order"] // TODO: infill_mesh settigns are excluded hardcoded, but should be based on the fact that settable_globally, settable_per_meshgroup and settable_per_extruder are false.
- expanded: Printer.expandedCategories
- onExpandedChanged: Printer.setExpandedCategories(expanded)
- onVisibilityChanged: Cura.SettingInheritanceManager.forceUpdate()
- }
-
- delegate: Loader
+ filterContainer.visible = !filterContainer.visible
+ if(filterContainer.visible)
{
- id: delegate
-
- width: UM.Theme.getSize("sidebar").width;
- height: provider.properties.enabled == "True" ? UM.Theme.getSize("section").height : - contents.spacing
- Behavior on height { NumberAnimation { duration: 100 } }
- opacity: provider.properties.enabled == "True" ? 1 : 0
- Behavior on opacity { NumberAnimation { duration: 100 } }
- enabled:
- {
- if(!ExtruderManager.activeExtruderStackId && ExtruderManager.extruderCount > 0)
- {
- // disable all controls on the global tab, except categories
- return model.type == "category"
- }
- return provider.properties.enabled == "True"
- }
-
- property var definition: model
- property var settingDefinitionsModel: definitionsModel
- property var propertyProvider: provider
- property var globalPropertyProvider: inheritStackProvider
-
- //Qt5.4.2 and earlier has a bug where this causes a crash: https://bugreports.qt.io/browse/QTBUG-35989
- //In addition, while it works for 5.5 and higher, the ordering of the actual combo box drop down changes,
- //causing nasty issues when selecting different options. So disable asynchronous loading of enum type completely.
- asynchronous: model.type != "enum" && model.type != "extruder"
- active: model.type != undefined
-
- source:
- {
- switch(model.type)
- {
- case "int":
- return "SettingTextField.qml"
- case "float":
- return "SettingTextField.qml"
- case "enum":
- return "SettingComboBox.qml"
- case "extruder":
- return "SettingExtruder.qml"
- case "bool":
- return "SettingCheckBox.qml"
- case "str":
- return "SettingTextField.qml"
- case "category":
- return "SettingCategory.qml"
- default:
- return "SettingUnknown.qml"
- }
- }
-
- // Binding to ensure that the right containerstack ID is set for the provider.
- // This ensures that if a setting has a limit_to_extruder id (for instance; Support speed points to the
- // extruder that actually prints the support, as that is the setting we need to use to calculate the value)
- Binding
- {
- target: provider
- property: "containerStackId"
- when: model.settable_per_extruder || (inheritStackProvider.properties.limit_to_extruder != null && inheritStackProvider.properties.limit_to_extruder >= 0);
- value:
- {
- if(!model.settable_per_extruder || machineExtruderCount.properties.value == 1)
- {
- //Not settable per extruder or there only is global, so we must pick global.
- return Cura.MachineManager.activeMachineId;
- }
- if(inheritStackProvider.properties.limit_to_extruder != null && inheritStackProvider.properties.limit_to_extruder >= 0)
- {
- //We have limit_to_extruder, so pick that stack.
- return ExtruderManager.extruderIds[String(inheritStackProvider.properties.limit_to_extruder)];
- }
- if(ExtruderManager.activeExtruderStackId)
- {
- //We're on an extruder tab. Pick the current extruder.
- return ExtruderManager.activeExtruderStackId;
- }
- //No extruder tab is selected. Pick the global stack. Shouldn't happen any more since we removed the global tab.
- return Cura.MachineManager.activeMachineId;
- }
- }
-
- // Specialty provider that only watches global_inherits (we cant filter on what property changed we get events
- // so we bypass that to make a dedicated provider).
- UM.SettingPropertyProvider
- {
- id: inheritStackProvider
- containerStackId: Cura.MachineManager.activeMachineId
- key: model.key
- watchedProperties: [ "limit_to_extruder" ]
- }
-
- UM.SettingPropertyProvider
- {
- id: provider
-
- containerStackId: Cura.MachineManager.activeMachineId
- key: model.key ? model.key : ""
- watchedProperties: [ "value", "enabled", "state", "validationState", "settable_per_extruder", "resolve" ]
- storeIndex: 0
- // Due to the way setPropertyValue works, removeUnusedValue gives the correct output in case of resolve
- removeUnusedValue: model.resolve == undefined
- }
-
- Connections
- {
- target: item
- onContextMenuRequested:
- {
- contextMenu.key = model.key;
- contextMenu.provider = provider
- contextMenu.popup();
- }
- onShowTooltip: base.showTooltip(delegate, { x: 0, y: delegate.height / 2 }, text)
- onHideTooltip: base.hideTooltip()
- onShowAllHiddenInheritedSettings:
- {
- var children_with_override = Cura.SettingInheritanceManager.getChildrenKeysWithOverride(category_id)
- for(var i = 0; i < children_with_override.length; i++)
- {
- definitionsModel.setVisible(children_with_override[i], true)
- }
- Cura.SettingInheritanceManager.manualRemoveOverride(category_id)
- }
- }
+ filter.forceActiveFocus();
}
-
- UM.I18nCatalog { id: catalog; name: "uranium"; }
-
- add: Transition {
- SequentialAnimation {
- NumberAnimation { properties: "height"; from: 0; duration: 100 }
- NumberAnimation { properties: "opacity"; from: 0; duration: 100 }
- }
- }
- remove: Transition {
- SequentialAnimation {
- NumberAnimation { properties: "opacity"; to: 0; duration: 100 }
- NumberAnimation { properties: "height"; to: 0; duration: 100 }
- }
- }
- addDisplaced: Transition {
- NumberAnimation { properties: "x,y"; duration: 100 }
- }
- removeDisplaced: Transition {
- SequentialAnimation {
- PauseAnimation { duration: 100; }
- NumberAnimation { properties: "x,y"; duration: 100 }
- }
- }
-
- Menu
+ else
{
- id: contextMenu
-
- property string key
- property var provider
-
- MenuItem
- {
- //: Settings context menu action
- text: catalog.i18nc("@action:menu", "Copy value to all extruders")
- visible: machineExtruderCount.properties.value > 1
- enabled: contextMenu.provider != undefined && contextMenu.provider.properties.settable_per_extruder != "False"
- onTriggered: Cura.MachineManager.copyValueToExtruders(contextMenu.key)
- }
-
- MenuSeparator
- {
- visible: machineExtruderCount.properties.value > 1
- }
-
- MenuItem
- {
- //: Settings context menu action
- text: catalog.i18nc("@action:menu", "Hide this setting");
- onTriggered: definitionsModel.hide(contextMenu.key);
- }
- MenuItem
- {
- //: Settings context menu action
- text: catalog.i18nc("@action:menu", "Configure setting visiblity...");
-
- onTriggered: Cura.Actions.configureSettingVisibility.trigger(contextMenu);
- }
- }
-
- UM.SettingPropertyProvider
- {
- id: machineExtruderCount
-
- containerStackId: Cura.MachineManager.activeMachineId
- key: "machine_extruder_count"
- watchedProperties: [ "value" ]
- storeIndex: 0
+ filter.text = "";
}
}
-}
+
+ Rectangle
+ {
+ id: filterContainer
+ visible: false
+
+ border.width: UM.Theme.getSize("default_lining").width
+ border.color:
+ {
+ if(hoverMouseArea.containsMouse || clearFilterButton.containsMouse)
+ {
+ return UM.Theme.getColor("setting_control_border_highlight");
+ }
+ else
+ {
+ return UM.Theme.getColor("setting_control_border");
+ }
+ }
+
+ color: UM.Theme.getColor("setting_control")
+
+ anchors
+ {
+ top: parent.top
+ left: parent.left
+ leftMargin: UM.Theme.getSize("default_margin").width
+ right: parent.right
+ rightMargin: UM.Theme.getSize("default_margin").width
+ }
+ height: visible ? UM.Theme.getSize("setting_control").height : 0
+ Behavior on height { NumberAnimation { duration: 100 } }
+
+ TextField
+ {
+ id: filter;
+
+ anchors.left: parent.left
+ anchors.right: clearFilterButton.left
+ anchors.rightMargin: UM.Theme.getSize("default_margin").width
+
+ placeholderText: catalog.i18nc("@label:textbox", "Filter...")
+
+ style: TextFieldStyle
+ {
+ textColor: UM.Theme.getColor("setting_control_text");
+ font: UM.Theme.getFont("default");
+ background: Item {}
+ }
+
+ property var expandedCategories
+ property bool lastFindingSettings: false
+
+ onTextChanged:
+ {
+ definitionsModel.filter = {"label": "*" + text};
+ findingSettings = (text.length > 0);
+ if(findingSettings != lastFindingSettings)
+ {
+ if(findingSettings)
+ {
+ expandedCategories = definitionsModel.expanded.slice();
+ definitionsModel.expanded = ["*"];
+ definitionsModel.showAncestors = true;
+ definitionsModel.showAll = true;
+ }
+ else
+ {
+ definitionsModel.expanded = expandedCategories;
+ definitionsModel.showAncestors = false;
+ definitionsModel.showAll = false;
+ }
+ lastFindingSettings = findingSettings;
+ }
+ }
+
+ Keys.onEscapePressed:
+ {
+ filter.text = "";
+ }
+ }
+
+ MouseArea
+ {
+ id: hoverMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton
+ cursorShape: Qt.IBeamCursor
+ }
+
+ UM.SimpleButton
+ {
+ id: clearFilterButton
+ iconSource: UM.Theme.getIcon("cross1")
+ visible: findingSettings
+
+ height: parent.height * 0.4
+ width: visible ? height : 0
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: UM.Theme.getSize("default_margin").width
+
+ color: UM.Theme.getColor("setting_control_button")
+ hoverColor: UM.Theme.getColor("setting_control_button_hover")
+
+ onClicked:
+ {
+ filter.text = "";
+ filter.forceActiveFocus();
+ }
+ }
+ }
+
+ ScrollView
+ {
+ anchors.top: filterContainer.bottom;
+ anchors.bottom: parent.bottom;
+ anchors.right: parent.right;
+ anchors.left: parent.left;
+ anchors.topMargin: filterContainer.visible ? UM.Theme.getSize("default_margin").width : 0
+ Behavior on anchors.topMargin { NumberAnimation { duration: 100 } }
+
+ style: UM.Theme.styles.scrollview;
+ flickableItem.flickableDirection: Flickable.VerticalFlick;
+
+ ListView
+ {
+ id: contents
+ spacing: UM.Theme.getSize("default_lining").height;
+ cacheBuffer: 1000000; // Set a large cache to effectively just cache every list item.
+
+ model: UM.SettingDefinitionsModel
+ {
+ id: definitionsModel;
+ containerId: Cura.MachineManager.activeDefinitionId
+ visibilityHandler: UM.SettingPreferenceVisibilityHandler { }
+ exclude: ["machine_settings", "command_line_settings", "infill_mesh", "infill_mesh_order"] // TODO: infill_mesh settigns are excluded hardcoded, but should be based on the fact that settable_globally, settable_per_meshgroup and settable_per_extruder are false.
+ expanded: Printer.expandedCategories
+ onExpandedChanged:
+ {
+ if(!findingSettings)
+ {
+ // Do not change expandedCategories preference while filtering settings
+ // because all categories are expanded while filtering
+ Printer.setExpandedCategories(expanded)
+ }
+ }
+ onVisibilityChanged: Cura.SettingInheritanceManager.forceUpdate()
+ }
+
+ delegate: Loader
+ {
+ id: delegate
+
+ width: UM.Theme.getSize("sidebar").width;
+ height: provider.properties.enabled == "True" ? UM.Theme.getSize("section").height : - contents.spacing
+ Behavior on height { NumberAnimation { duration: 100 } }
+ opacity: provider.properties.enabled == "True" ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 100 } }
+ enabled:
+ {
+ if(!ExtruderManager.activeExtruderStackId && ExtruderManager.extruderCount > 0)
+ {
+ // disable all controls on the global tab, except categories
+ return model.type == "category"
+ }
+ return provider.properties.enabled == "True"
+ }
+
+ property var definition: model
+ property var settingDefinitionsModel: definitionsModel
+ property var propertyProvider: provider
+ property var globalPropertyProvider: inheritStackProvider
+
+ //Qt5.4.2 and earlier has a bug where this causes a crash: https://bugreports.qt.io/browse/QTBUG-35989
+ //In addition, while it works for 5.5 and higher, the ordering of the actual combo box drop down changes,
+ //causing nasty issues when selecting different options. So disable asynchronous loading of enum type completely.
+ asynchronous: model.type != "enum" && model.type != "extruder"
+ active: model.type != undefined
+
+ source:
+ {
+ switch(model.type)
+ {
+ case "int":
+ return "SettingTextField.qml"
+ case "float":
+ return "SettingTextField.qml"
+ case "enum":
+ return "SettingComboBox.qml"
+ case "extruder":
+ return "SettingExtruder.qml"
+ case "bool":
+ return "SettingCheckBox.qml"
+ case "str":
+ return "SettingTextField.qml"
+ case "category":
+ return "SettingCategory.qml"
+ default:
+ return "SettingUnknown.qml"
+ }
+ }
+
+ // Binding to ensure that the right containerstack ID is set for the provider.
+ // This ensures that if a setting has a limit_to_extruder id (for instance; Support speed points to the
+ // extruder that actually prints the support, as that is the setting we need to use to calculate the value)
+ Binding
+ {
+ target: provider
+ property: "containerStackId"
+ when: model.settable_per_extruder || (inheritStackProvider.properties.limit_to_extruder != null && inheritStackProvider.properties.limit_to_extruder >= 0);
+ value:
+ {
+ if(!model.settable_per_extruder || machineExtruderCount.properties.value == 1)
+ {
+ //Not settable per extruder or there only is global, so we must pick global.
+ return Cura.MachineManager.activeMachineId;
+ }
+ if(inheritStackProvider.properties.limit_to_extruder != null && inheritStackProvider.properties.limit_to_extruder >= 0)
+ {
+ //We have limit_to_extruder, so pick that stack.
+ return ExtruderManager.extruderIds[String(inheritStackProvider.properties.limit_to_extruder)];
+ }
+ if(ExtruderManager.activeExtruderStackId)
+ {
+ //We're on an extruder tab. Pick the current extruder.
+ return ExtruderManager.activeExtruderStackId;
+ }
+ //No extruder tab is selected. Pick the global stack. Shouldn't happen any more since we removed the global tab.
+ return Cura.MachineManager.activeMachineId;
+ }
+ }
+
+ // Specialty provider that only watches global_inherits (we cant filter on what property changed we get events
+ // so we bypass that to make a dedicated provider).
+ UM.SettingPropertyProvider
+ {
+ id: inheritStackProvider
+ containerStackId: Cura.MachineManager.activeMachineId
+ key: model.key
+ watchedProperties: [ "limit_to_extruder" ]
+ }
+
+ UM.SettingPropertyProvider
+ {
+ id: provider
+
+ containerStackId: Cura.MachineManager.activeMachineId
+ key: model.key ? model.key : ""
+ watchedProperties: [ "value", "enabled", "state", "validationState", "settable_per_extruder", "resolve" ]
+ storeIndex: 0
+ // Due to the way setPropertyValue works, removeUnusedValue gives the correct output in case of resolve
+ removeUnusedValue: model.resolve == undefined
+ }
+
+ Connections
+ {
+ target: item
+ onContextMenuRequested:
+ {
+ contextMenu.key = model.key;
+ contextMenu.settingVisible = model.visible;
+ contextMenu.provider = provider
+ contextMenu.popup();
+ }
+ onShowTooltip: base.showTooltip(delegate, { x: 0, y: delegate.height / 2 }, text)
+ onHideTooltip: base.hideTooltip()
+ onShowAllHiddenInheritedSettings:
+ {
+ var children_with_override = Cura.SettingInheritanceManager.getChildrenKeysWithOverride(category_id)
+ for(var i = 0; i < children_with_override.length; i++)
+ {
+ definitionsModel.setVisible(children_with_override[i], true)
+ }
+ Cura.SettingInheritanceManager.manualRemoveOverride(category_id)
+ }
+ }
+ }
+
+ UM.I18nCatalog { id: catalog; name: "uranium"; }
+
+ add: Transition {
+ SequentialAnimation {
+ NumberAnimation { properties: "height"; from: 0; duration: 100 }
+ NumberAnimation { properties: "opacity"; from: 0; duration: 100 }
+ }
+ }
+ remove: Transition {
+ SequentialAnimation {
+ NumberAnimation { properties: "opacity"; to: 0; duration: 100 }
+ NumberAnimation { properties: "height"; to: 0; duration: 100 }
+ }
+ }
+ addDisplaced: Transition {
+ NumberAnimation { properties: "x,y"; duration: 100 }
+ }
+ removeDisplaced: Transition {
+ SequentialAnimation {
+ PauseAnimation { duration: 100; }
+ NumberAnimation { properties: "x,y"; duration: 100 }
+ }
+ }
+
+ Menu
+ {
+ id: contextMenu
+
+ property string key
+ property var provider
+ property bool settingVisible
+
+ MenuItem
+ {
+ //: Settings context menu action
+ text: catalog.i18nc("@action:menu", "Copy value to all extruders")
+ visible: machineExtruderCount.properties.value > 1
+ enabled: contextMenu.provider != undefined && contextMenu.provider.properties.settable_per_extruder != "False"
+ onTriggered: Cura.MachineManager.copyValueToExtruders(contextMenu.key)
+ }
+
+ MenuSeparator
+ {
+ visible: machineExtruderCount.properties.value > 1
+ }
+
+ MenuItem
+ {
+ //: Settings context menu action
+ visible: !findingSettings;
+ text: catalog.i18nc("@action:menu", "Hide this setting");
+ onTriggered: definitionsModel.hide(contextMenu.key);
+ }
+ MenuItem
+ {
+ //: Settings context menu action
+ text:
+ {
+ if (contextMenu.settingVisible)
+ {
+ return catalog.i18nc("@action:menu", "Don't show this setting");
+ }
+ else
+ {
+ return catalog.i18nc("@action:menu", "Keep this setting visible");
+ }
+ }
+ visible: findingSettings;
+ onTriggered:
+ {
+ if (contextMenu.settingVisible)
+ {
+ definitionsModel.hide(contextMenu.key);
+ }
+ else
+ {
+ definitionsModel.show(contextMenu.key);
+ }
+ }
+ }
+ MenuItem
+ {
+ //: Settings context menu action
+ text: catalog.i18nc("@action:menu", "Configure setting visiblity...");
+
+ onTriggered: Cura.Actions.configureSettingVisibility.trigger(contextMenu);
+ }
+ }
+
+ UM.SettingPropertyProvider
+ {
+ id: machineExtruderCount
+
+ containerStackId: Cura.MachineManager.activeMachineId
+ key: "machine_extruder_count"
+ watchedProperties: [ "value" ]
+ storeIndex: 0
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/resources/qml/Sidebar.qml b/resources/qml/Sidebar.qml
index 1c1eb5e5a0..9c0d641d77 100644
--- a/resources/qml/Sidebar.qml
+++ b/resources/qml/Sidebar.qml
@@ -214,7 +214,7 @@ Rectangle
anchors.left: parent.left
anchors.leftMargin: model.index * (settingsModeSelection.width / 2)
anchors.verticalCenter: parent.verticalCenter
- width: parent.width / 2
+ width: 0.5 * parent.width - (model.showFilterButton ? toggleFilterButton.width : 0)
text: model.text
exclusiveGroup: modeMenuGroup;
checkable: true;
@@ -256,6 +256,44 @@ Rectangle
}
}
+ Button
+ {
+ id: toggleFilterButton
+
+ anchors.right: parent.right
+ anchors.rightMargin: UM.Theme.getSize("default_margin").width
+ anchors.top: headerSeparator.bottom
+ anchors.topMargin: UM.Theme.getSize("default_margin").height
+
+ height: settingsModeSelection.height
+ width: visible ? height : 0
+
+ visible: !monitoringPrint && modesListModel.get(base.currentModeIndex).showFilterButton
+ opacity: visible ? 1 : 0
+
+ onClicked: sidebarContents.currentItem.toggleFilterField()
+
+ style: ButtonStyle
+ {
+ background: Rectangle
+ {
+ border.width: UM.Theme.getSize("default_lining").width
+ border.color: UM.Theme.getColor("toggle_checked_border")
+ color: visible ? UM.Theme.getColor("toggle_checked") : UM.Theme.getColor("toggle_hovered")
+ Behavior on color { ColorAnimation { duration: 50; } }
+ }
+ label: UM.RecolorImage
+ {
+ anchors.verticalCenter: control.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: UM.Theme.getSize("default_margin").width / 2
+
+ source: UM.Theme.getIcon("search")
+ color: UM.Theme.getColor("toggle_checked_text")
+ }
+ }
+ }
+
Label {
id: monitorLabel
text: catalog.i18nc("@label","Printer Monitor");
@@ -379,8 +417,8 @@ Rectangle
Component.onCompleted:
{
- modesListModel.append({ text: catalog.i18nc("@title:tab", "Recommended"), item: sidebarSimple })
- modesListModel.append({ text: catalog.i18nc("@title:tab", "Custom"), item: sidebarAdvanced })
+ modesListModel.append({ text: catalog.i18nc("@title:tab", "Recommended"), item: sidebarSimple, showFilterButton: false })
+ modesListModel.append({ text: catalog.i18nc("@title:tab", "Custom"), item: sidebarAdvanced, showFilterButton: true })
sidebarContents.push({ "item": modesListModel.get(base.currentModeIndex).item, "immediate": true });
}
diff --git a/resources/qml/SidebarHeader.qml b/resources/qml/SidebarHeader.qml
index 700384c394..e894392b06 100644
--- a/resources/qml/SidebarHeader.qml
+++ b/resources/qml/SidebarHeader.qml
@@ -329,7 +329,7 @@ Column
}
onEntered:
{
- var content = catalog.i18nc("@tooltip","Some setting values are different from the values stored in the profile.\n\nClick to open the profile manager.")
+ var content = catalog.i18nc("@tooltip","Some setting/override values are different from the values stored in the profile.\n\nClick to open the profile manager.")
base.showTooltip(globalProfileRow, Qt.point(0, globalProfileRow.height / 2), content)
}
onExited: base.hideTooltip()
diff --git a/resources/quality/ultimaker2_plus/pla_0.25_normal.inst.cfg b/resources/quality/ultimaker2_plus/pla_0.25_normal.inst.cfg
index 868f1dc016..db2b48b3cc 100644
--- a/resources/quality/ultimaker2_plus/pla_0.25_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/pla_0.25_normal.inst.cfg
@@ -15,5 +15,6 @@ wall_thickness = 0.88
top_bottom_thickness = 0.72
infill_sparse_density = 22
speed_print = 30
+speed_layer_0 = =round(speed_print * 30 / 30)
cool_min_layer_time = 5
cool_min_speed = 10
diff --git a/resources/quality/ultimaker2_plus/pla_0.4_fast.inst.cfg b/resources/quality/ultimaker2_plus/pla_0.4_fast.inst.cfg
index b17a1f2a6a..d3f2740202 100644
--- a/resources/quality/ultimaker2_plus/pla_0.4_fast.inst.cfg
+++ b/resources/quality/ultimaker2_plus/pla_0.4_fast.inst.cfg
@@ -15,9 +15,9 @@ wall_thickness = 0.7
top_bottom_thickness = 0.75
infill_sparse_density = 18
speed_print = 60
+speed_layer_0 = =round(speed_print * 30 / 60)
speed_wall = 50
speed_topbottom = 30
speed_travel = 150
-speed_layer_0 = 30
cool_min_layer_time = 5
cool_min_speed = 10
diff --git a/resources/quality/ultimaker2_plus/pla_0.4_high.inst.cfg b/resources/quality/ultimaker2_plus/pla_0.4_high.inst.cfg
index c2b15d1074..d3347b4712 100644
--- a/resources/quality/ultimaker2_plus/pla_0.4_high.inst.cfg
+++ b/resources/quality/ultimaker2_plus/pla_0.4_high.inst.cfg
@@ -15,6 +15,7 @@ wall_thickness = 1.05
top_bottom_thickness = 0.72
infill_sparse_density = 22
speed_print = 50
+speed_layer_0 = =round(speed_print * 30 / 50)
speed_topbottom = 20
cool_min_layer_time = 5
cool_min_speed = 10
diff --git a/resources/quality/ultimaker2_plus/pla_0.4_normal.inst.cfg b/resources/quality/ultimaker2_plus/pla_0.4_normal.inst.cfg
index 9a84f5c04a..758225535a 100644
--- a/resources/quality/ultimaker2_plus/pla_0.4_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/pla_0.4_normal.inst.cfg
@@ -15,6 +15,7 @@ wall_thickness = 1.05
top_bottom_thickness = 0.8
infill_sparse_density = 20
speed_print = 50
+speed_layer_0 = =round(speed_print * 30 / 50)
speed_topbottom = 20
cool_min_layer_time = 5
cool_min_speed = 10
diff --git a/resources/quality/ultimaker2_plus/pla_0.6_normal.inst.cfg b/resources/quality/ultimaker2_plus/pla_0.6_normal.inst.cfg
index fd4c2c120a..5eed5965e4 100644
--- a/resources/quality/ultimaker2_plus/pla_0.6_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/pla_0.6_normal.inst.cfg
@@ -15,6 +15,7 @@ wall_thickness = 1.59
top_bottom_thickness = 1.2
infill_sparse_density = 20
speed_print = 55
+speed_layer_0 = =round(speed_print * 30 / 55)
speed_wall = 40
speed_wall_0 = 25
speed_topbottom = 20
diff --git a/resources/quality/ultimaker2_plus/pla_0.8_normal.inst.cfg b/resources/quality/ultimaker2_plus/pla_0.8_normal.inst.cfg
index cd4c591640..96a81d874e 100644
--- a/resources/quality/ultimaker2_plus/pla_0.8_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/pla_0.8_normal.inst.cfg
@@ -15,6 +15,7 @@ wall_thickness = 2.1
top_bottom_thickness = 1.2
infill_sparse_density = 20
speed_print = 40
+speed_layer_0 = =round(speed_print * 30 / 40)
speed_wall_0 = 25
cool_min_layer_time = 5
cool_min_speed = 10
diff --git a/resources/quality/ultimaker2_plus/um2p_abs_0.25_normal.inst.cfg b/resources/quality/ultimaker2_plus/um2p_abs_0.25_normal.inst.cfg
index 94ddd58081..afe7c52f1a 100644
--- a/resources/quality/ultimaker2_plus/um2p_abs_0.25_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_abs_0.25_normal.inst.cfg
@@ -15,6 +15,7 @@ wall_thickness = 0.88
top_bottom_thickness = 0.72
infill_sparse_density = 22
speed_print = 30
+speed_layer_0 = =round(speed_print * 30 / 30)
cool_min_layer_time = 3
cool_fan_speed_min = 20
cool_min_speed = 10
diff --git a/resources/quality/ultimaker2_plus/um2p_abs_0.4_fast.inst.cfg b/resources/quality/ultimaker2_plus/um2p_abs_0.4_fast.inst.cfg
index 8bcb3efee4..4eff2c3d91 100644
--- a/resources/quality/ultimaker2_plus/um2p_abs_0.4_fast.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_abs_0.4_fast.inst.cfg
@@ -15,10 +15,10 @@ wall_thickness = 0.7
top_bottom_thickness = 0.75
infill_sparse_density = 18
speed_print = 55
+speed_layer_0 = =round(speed_print * 30 / 55)
speed_wall = 40
speed_topbottom = 30
speed_travel = 150
-speed_layer_0 = 30
cool_min_layer_time = 3
cool_fan_speed_min = 20
cool_min_speed = 10
diff --git a/resources/quality/ultimaker2_plus/um2p_abs_0.4_high.inst.cfg b/resources/quality/ultimaker2_plus/um2p_abs_0.4_high.inst.cfg
index c6ea33da2d..607598b249 100644
--- a/resources/quality/ultimaker2_plus/um2p_abs_0.4_high.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_abs_0.4_high.inst.cfg
@@ -15,6 +15,7 @@ wall_thickness = 1.05
top_bottom_thickness = 0.72
infill_sparse_density = 22
speed_print = 45
+speed_layer_0 = =round(speed_print * 30 / 45)
speed_wall = 30
cool_min_layer_time = 3
cool_fan_speed_min = 20
diff --git a/resources/quality/ultimaker2_plus/um2p_abs_0.4_normal.inst.cfg b/resources/quality/ultimaker2_plus/um2p_abs_0.4_normal.inst.cfg
index ba7886276b..be379beb30 100644
--- a/resources/quality/ultimaker2_plus/um2p_abs_0.4_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_abs_0.4_normal.inst.cfg
@@ -15,6 +15,7 @@ wall_thickness = 1.05
top_bottom_thickness = 0.8
infill_sparse_density = 20
speed_print = 45
+speed_layer_0 = =round(speed_print * 30 / 45)
speed_wall = 30
cool_min_layer_time = 3
cool_fan_speed_min = 20
diff --git a/resources/quality/ultimaker2_plus/um2p_abs_0.6_normal.inst.cfg b/resources/quality/ultimaker2_plus/um2p_abs_0.6_normal.inst.cfg
index 3a0af33a6a..b988738273 100644
--- a/resources/quality/ultimaker2_plus/um2p_abs_0.6_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_abs_0.6_normal.inst.cfg
@@ -15,6 +15,7 @@ wall_thickness = 1.59
top_bottom_thickness = 1.2
infill_sparse_density = 20
speed_print = 40
+speed_layer_0 = =round(speed_print * 30 / 40)
speed_infill = 55
cool_min_layer_time = 3
cool_fan_speed_min = 50
diff --git a/resources/quality/ultimaker2_plus/um2p_abs_0.8_normal.inst.cfg b/resources/quality/ultimaker2_plus/um2p_abs_0.8_normal.inst.cfg
index e8885fc73a..c6954c92d8 100644
--- a/resources/quality/ultimaker2_plus/um2p_abs_0.8_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_abs_0.8_normal.inst.cfg
@@ -15,6 +15,7 @@ wall_thickness = 2.1
top_bottom_thickness = 1.2
infill_sparse_density = 20
speed_print = 40
+speed_layer_0 = =round(speed_print * 30 / 40)
cool_min_layer_time = 3
cool_fan_speed_min = 50
cool_min_speed = 15
diff --git a/resources/quality/ultimaker2_plus/um2p_cpe_0.25_normal.inst.cfg b/resources/quality/ultimaker2_plus/um2p_cpe_0.25_normal.inst.cfg
index b86bb877f6..0128800950 100644
--- a/resources/quality/ultimaker2_plus/um2p_cpe_0.25_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_cpe_0.25_normal.inst.cfg
@@ -15,6 +15,7 @@ wall_thickness = 0.88
top_bottom_thickness = 0.72
infill_sparse_density = 22
speed_print = 30
+speed_layer_0 = =round(speed_print * 30 / 30)
cool_min_layer_time = 2
cool_fan_speed_min = 20
cool_min_speed = 15
diff --git a/resources/quality/ultimaker2_plus/um2p_cpe_0.4_fast.inst.cfg b/resources/quality/ultimaker2_plus/um2p_cpe_0.4_fast.inst.cfg
index 8f8fb9e01b..0c3fec0afa 100644
--- a/resources/quality/ultimaker2_plus/um2p_cpe_0.4_fast.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_cpe_0.4_fast.inst.cfg
@@ -15,9 +15,9 @@ wall_thickness = 0.7
top_bottom_thickness = 0.75
infill_sparse_density = 18
speed_print = 45
+speed_layer_0 = =round(speed_print * 30 / 45)
speed_wall = 40
speed_travel = 150
-speed_layer_0 = 30
cool_min_layer_time = 3
cool_fan_speed_min = 80
cool_min_speed = 10
diff --git a/resources/quality/ultimaker2_plus/um2p_cpe_0.4_high.inst.cfg b/resources/quality/ultimaker2_plus/um2p_cpe_0.4_high.inst.cfg
index bb6a1ee079..597d450bd4 100644
--- a/resources/quality/ultimaker2_plus/um2p_cpe_0.4_high.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_cpe_0.4_high.inst.cfg
@@ -15,6 +15,7 @@ wall_thickness = 1.05
top_bottom_thickness = 0.72
infill_sparse_density = 22
speed_print = 45
+speed_layer_0 = =round(speed_print * 30 / 45)
speed_wall = 30
cool_min_layer_time = 2
cool_fan_speed_min = 80
diff --git a/resources/quality/ultimaker2_plus/um2p_cpe_0.4_normal.inst.cfg b/resources/quality/ultimaker2_plus/um2p_cpe_0.4_normal.inst.cfg
index 54122164da..1d624aeb33 100644
--- a/resources/quality/ultimaker2_plus/um2p_cpe_0.4_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_cpe_0.4_normal.inst.cfg
@@ -15,6 +15,7 @@ wall_thickness = 1.05
top_bottom_thickness = 0.8
infill_sparse_density = 20
speed_print = 45
+speed_layer_0 = =round(speed_print * 30 / 45)
speed_wall = 30
cool_min_layer_time = 3
cool_fan_speed_min = 80
diff --git a/resources/quality/ultimaker2_plus/um2p_cpe_0.6_normal.inst.cfg b/resources/quality/ultimaker2_plus/um2p_cpe_0.6_normal.inst.cfg
index 00a6160f46..c9c9fbf88c 100644
--- a/resources/quality/ultimaker2_plus/um2p_cpe_0.6_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_cpe_0.6_normal.inst.cfg
@@ -15,6 +15,7 @@ wall_thickness = 1.59
top_bottom_thickness = 1.2
infill_sparse_density = 20
speed_print = 40
+speed_layer_0 = =round(speed_print * 30 / 40)
cool_min_layer_time = 5
cool_fan_speed_min = 80
cool_min_speed = 8
diff --git a/resources/quality/ultimaker2_plus/um2p_cpe_0.8_normal.inst.cfg b/resources/quality/ultimaker2_plus/um2p_cpe_0.8_normal.inst.cfg
index 83714ca40a..9f02a97a36 100644
--- a/resources/quality/ultimaker2_plus/um2p_cpe_0.8_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_cpe_0.8_normal.inst.cfg
@@ -15,6 +15,7 @@ wall_thickness = 2.1
top_bottom_thickness = 1.2
infill_sparse_density = 20
speed_print = 40
+speed_layer_0 = =round(speed_print * 30 / 40)
cool_min_layer_time = 3
cool_fan_speed_min = 80
cool_min_speed = 8
diff --git a/resources/quality/ultimaker2_plus/um2p_cpep_0.6_draft.inst.cfg b/resources/quality/ultimaker2_plus/um2p_cpep_0.6_draft.inst.cfg
index be6e962949..a8d90b65ef 100644
--- a/resources/quality/ultimaker2_plus/um2p_cpep_0.6_draft.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_cpep_0.6_draft.inst.cfg
@@ -20,7 +20,6 @@ raft_surface_thickness = 0.2
raft_surface_line_width = 0.57
raft_interface_line_spacing = 1.4
raft_margin = 15
-speed_layer_0 = 30
raft_airgap = 0.37
infill_overlap = 5
layer_height = 0.3
@@ -40,6 +39,7 @@ line_width = 0.57
layer_0_z_overlap = 0.22
raft_base_line_width = 1.2
speed_print = 25
+speed_layer_0 = =round(speed_print * 30 / 50)
support_line_distance = 2.85
support_angle = 45
cool_min_layer_time = 3
diff --git a/resources/quality/ultimaker2_plus/um2p_cpep_0.8_draft.inst.cfg b/resources/quality/ultimaker2_plus/um2p_cpep_0.8_draft.inst.cfg
index 2cd6ef9dac..a16708e4ff 100644
--- a/resources/quality/ultimaker2_plus/um2p_cpep_0.8_draft.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_cpep_0.8_draft.inst.cfg
@@ -33,6 +33,7 @@ infill_sparse_density = 40
layer_0_z_overlap = 0.22
raft_base_line_width = 1.6
speed_print = 25
+speed_layer_0 = =round(speed_print * 30 / 25)
speed_wall_0 = 20
support_angle = 45
cool_min_layer_time = 3
diff --git a/resources/quality/ultimaker2_plus/um2p_cpep_0.8_normal.inst.cfg b/resources/quality/ultimaker2_plus/um2p_cpep_0.8_normal.inst.cfg
index f243637cd7..0cd03af871 100644
--- a/resources/quality/ultimaker2_plus/um2p_cpep_0.8_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_cpep_0.8_normal.inst.cfg
@@ -33,6 +33,7 @@ infill_sparse_density = 40
layer_0_z_overlap = 0.22
raft_base_line_width = 1.6
speed_print = 30
+speed_layer_0 = =round(speed_print * 30 / 30)
speed_wall_0 = 20
support_angle = 45
cool_min_layer_time = 3
diff --git a/resources/quality/ultimaker2_plus/um2p_nylon_0.8_normal.inst.cfg b/resources/quality/ultimaker2_plus/um2p_nylon_0.8_normal.inst.cfg
index 023ffd7498..223e42291e 100644
--- a/resources/quality/ultimaker2_plus/um2p_nylon_0.8_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_nylon_0.8_normal.inst.cfg
@@ -20,7 +20,6 @@ support_top_distance = 0.5
raft_surface_thickness = 0.2
wall_thickness = 2.4
raft_margin = 15
-speed_layer_0 = 30
raft_airgap = 0.44
infill_overlap = 5
layer_height = 0.2
@@ -41,6 +40,7 @@ infill_sparse_density = 40
layer_0_z_overlap = 0.25
raft_base_line_width = 1.6
speed_print = 55
+speed_layer_0 = =round(speed_print * 30 / 55)
support_angle = 45
raft_interface_line_spacing = 1.8
diff --git a/resources/quality/ultimaker2_plus/um2p_pc_0.25_high.inst.cfg b/resources/quality/ultimaker2_plus/um2p_pc_0.25_high.inst.cfg
index 91e75c2450..a70d82d909 100644
--- a/resources/quality/ultimaker2_plus/um2p_pc_0.25_high.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_pc_0.25_high.inst.cfg
@@ -31,6 +31,7 @@ infill_sparse_density = 25
layer_0_z_overlap = 0.22
cool_min_layer_time = 2
speed_print = 30
+speed_layer_0 = =round(speed_print * 30 / 30)
raft_base_line_spacing = 1
raft_base_line_width = 0.5
diff --git a/resources/quality/ultimaker2_plus/um2p_pc_0.25_normal.inst.cfg b/resources/quality/ultimaker2_plus/um2p_pc_0.25_normal.inst.cfg
index a5df9972b9..b3ba1ecf8c 100644
--- a/resources/quality/ultimaker2_plus/um2p_pc_0.25_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_pc_0.25_normal.inst.cfg
@@ -31,6 +31,7 @@ infill_sparse_density = 25
layer_0_z_overlap = 0.22
cool_min_layer_time = 2
speed_print = 30
+speed_layer_0 = =round(speed_print * 30 / 30)
raft_base_line_spacing = 1
raft_base_line_width = 0.5
diff --git a/resources/quality/ultimaker2_plus/um2p_pc_0.4_fast.inst.cfg b/resources/quality/ultimaker2_plus/um2p_pc_0.4_fast.inst.cfg
index e05cbb0dd0..a9b8418bcb 100644
--- a/resources/quality/ultimaker2_plus/um2p_pc_0.4_fast.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_pc_0.4_fast.inst.cfg
@@ -31,6 +31,7 @@ infill_sparse_density = 30
layer_0_z_overlap = 0.22
cool_min_layer_time = 3
speed_print = 45
+speed_layer_0 = =round(speed_print * 30 / 45)
support_angle = 45
raft_base_line_spacing = 1.6
raft_base_line_width = 0.8
diff --git a/resources/quality/ultimaker2_plus/um2p_pc_0.4_normal.inst.cfg b/resources/quality/ultimaker2_plus/um2p_pc_0.4_normal.inst.cfg
index 0b79ed29bd..e111597c2d 100644
--- a/resources/quality/ultimaker2_plus/um2p_pc_0.4_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_pc_0.4_normal.inst.cfg
@@ -31,6 +31,7 @@ infill_sparse_density = 30
layer_0_z_overlap = 0.22
cool_min_layer_time = 3
speed_print = 45
+speed_layer_0 = =round(speed_print * 30 / 45)
support_angle = 45
raft_base_line_spacing = 1.6
raft_base_line_width = 0.8
diff --git a/resources/quality/ultimaker2_plus/um2p_pc_0.8_draft.inst.cfg b/resources/quality/ultimaker2_plus/um2p_pc_0.8_draft.inst.cfg
index 52e61a8145..cd0d3b3695 100644
--- a/resources/quality/ultimaker2_plus/um2p_pc_0.8_draft.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_pc_0.8_draft.inst.cfg
@@ -32,6 +32,7 @@ infill_sparse_density = 40
layer_0_z_overlap = 0.22
raft_base_line_width = 1.6
speed_print = 40
+speed_layer_0 = =round(speed_print * 30 / 40)
support_angle = 45
cool_min_layer_time = 3
diff --git a/resources/quality/ultimaker2_plus/um2p_pc_0.8_normal.inst.cfg b/resources/quality/ultimaker2_plus/um2p_pc_0.8_normal.inst.cfg
index 383fe8722f..bdd774824e 100644
--- a/resources/quality/ultimaker2_plus/um2p_pc_0.8_normal.inst.cfg
+++ b/resources/quality/ultimaker2_plus/um2p_pc_0.8_normal.inst.cfg
@@ -32,6 +32,7 @@ infill_sparse_density = 40
layer_0_z_overlap = 0.22
raft_base_line_width = 1.6
speed_print = 40
+speed_layer_0 = =round(speed_print * 30 / 40)
support_angle = 45
cool_min_layer_time = 3
diff --git a/resources/quality/ultimaker3/um3_aa0.4_ABS_Draft_Print.inst.cfg b/resources/quality/ultimaker3/um3_aa0.4_ABS_Draft_Print.inst.cfg
index 6a1b477165..9ea0c2119f 100644
--- a/resources/quality/ultimaker3/um3_aa0.4_ABS_Draft_Print.inst.cfg
+++ b/resources/quality/ultimaker3/um3_aa0.4_ABS_Draft_Print.inst.cfg
@@ -16,6 +16,7 @@ material_print_temperature = 240
prime_tower_size = 16
skin_overlap = 20
speed_print = 60
+speed_layer_0 = =round(speed_print * 30 / 60)
speed_topbottom = =math.ceil(speed_print * 35 / 60)
speed_wall = =math.ceil(speed_print * 45 / 60)
speed_wall_0 = =math.ceil(speed_wall * 35 / 45)
diff --git a/resources/quality/ultimaker3/um3_aa0.4_ABS_Fast_Print.inst.cfg b/resources/quality/ultimaker3/um3_aa0.4_ABS_Fast_Print.inst.cfg
index 3b5d37024d..d5c012d0c6 100644
--- a/resources/quality/ultimaker3/um3_aa0.4_ABS_Fast_Print.inst.cfg
+++ b/resources/quality/ultimaker3/um3_aa0.4_ABS_Fast_Print.inst.cfg
@@ -17,6 +17,7 @@ material_print_temperature = 235
material_standby_temperature = 100
prime_tower_size = 16
speed_print = 60
+speed_layer_0 = =round(speed_print * 30 / 60)
speed_topbottom = =math.ceil(speed_print * 30 / 60)
speed_wall = =math.ceil(speed_print * 40 / 60)
speed_wall_0 = =math.ceil(speed_wall * 30 / 40)
diff --git a/resources/quality/ultimaker3/um3_aa0.4_ABS_High_Quality.inst.cfg b/resources/quality/ultimaker3/um3_aa0.4_ABS_High_Quality.inst.cfg
index dcb8e85563..10651f520c 100644
--- a/resources/quality/ultimaker3/um3_aa0.4_ABS_High_Quality.inst.cfg
+++ b/resources/quality/ultimaker3/um3_aa0.4_ABS_High_Quality.inst.cfg
@@ -16,6 +16,7 @@ machine_nozzle_heat_up_speed = 1.5
material_standby_temperature = 100
prime_tower_size = 16
speed_print = 50
+speed_layer_0 = =round(speed_print * 30 / 50)
speed_topbottom = =math.ceil(speed_print * 30 / 50)
speed_wall = =math.ceil(speed_print * 30 / 50)
diff --git a/resources/quality/ultimaker3/um3_aa0.4_ABS_Normal_Quality.inst.cfg b/resources/quality/ultimaker3/um3_aa0.4_ABS_Normal_Quality.inst.cfg
index 4e99ac446e..d0d4ec4d5c 100644
--- a/resources/quality/ultimaker3/um3_aa0.4_ABS_Normal_Quality.inst.cfg
+++ b/resources/quality/ultimaker3/um3_aa0.4_ABS_Normal_Quality.inst.cfg
@@ -16,6 +16,7 @@ material_print_temperature = 230
material_standby_temperature = 100
prime_tower_size = 16
speed_print = 55
+speed_layer_0 = =round(speed_print * 30 / 55)
speed_topbottom = =math.ceil(speed_print * 30 / 55)
speed_wall = =math.ceil(speed_print * 30 / 55)
diff --git a/resources/quality/ultimaker3/um3_aa0.4_CPE_Draft_Print.inst.cfg b/resources/quality/ultimaker3/um3_aa0.4_CPE_Draft_Print.inst.cfg
index d7e0ed62b6..6c9f031cfe 100644
--- a/resources/quality/ultimaker3/um3_aa0.4_CPE_Draft_Print.inst.cfg
+++ b/resources/quality/ultimaker3/um3_aa0.4_CPE_Draft_Print.inst.cfg
@@ -15,6 +15,7 @@ material_standby_temperature = 100
prime_tower_size = 17
skin_overlap = 20
speed_print = 60
+speed_layer_0 = =round(speed_print * 30 / 60)
speed_topbottom = =math.ceil(speed_print * 35 / 60)
speed_wall = =math.ceil(speed_print * 45 / 60)
speed_wall_0 = =math.ceil(speed_wall * 35 / 45)
diff --git a/resources/quality/ultimaker3/um3_aa0.4_CPE_Fast_Print.inst.cfg b/resources/quality/ultimaker3/um3_aa0.4_CPE_Fast_Print.inst.cfg
index 5717bf50fe..7430c63b1b 100644
--- a/resources/quality/ultimaker3/um3_aa0.4_CPE_Fast_Print.inst.cfg
+++ b/resources/quality/ultimaker3/um3_aa0.4_CPE_Fast_Print.inst.cfg
@@ -15,6 +15,7 @@ material_print_temperature = 245
material_standby_temperature = 100
prime_tower_size = 17
speed_print = 60
+speed_layer_0 = =round(speed_print * 30 / 60)
speed_topbottom = =math.ceil(speed_print * 30 / 60)
speed_wall = =math.ceil(speed_print * 40 / 60)
speed_wall_0 = =math.ceil(speed_wall * 30 / 40)
diff --git a/resources/quality/ultimaker3/um3_aa0.4_CPE_High_Quality.inst.cfg b/resources/quality/ultimaker3/um3_aa0.4_CPE_High_Quality.inst.cfg
index e058ef8cac..e428c4c511 100644
--- a/resources/quality/ultimaker3/um3_aa0.4_CPE_High_Quality.inst.cfg
+++ b/resources/quality/ultimaker3/um3_aa0.4_CPE_High_Quality.inst.cfg
@@ -16,6 +16,7 @@ machine_nozzle_heat_up_speed = 1.5
material_standby_temperature = 100
prime_tower_size = 17
speed_print = 50
+speed_layer_0 = =round(speed_print * 30 / 50)
speed_topbottom = =math.ceil(speed_print * 30 / 50)
speed_wall = =math.ceil(speed_print * 30 / 50)
diff --git a/resources/quality/ultimaker3/um3_aa0.4_CPE_Normal_Quality.inst.cfg b/resources/quality/ultimaker3/um3_aa0.4_CPE_Normal_Quality.inst.cfg
index 1ccd1c54d3..a628afbe78 100644
--- a/resources/quality/ultimaker3/um3_aa0.4_CPE_Normal_Quality.inst.cfg
+++ b/resources/quality/ultimaker3/um3_aa0.4_CPE_Normal_Quality.inst.cfg
@@ -16,6 +16,7 @@ material_print_temperature = 240
material_standby_temperature = 100
prime_tower_size = 17
speed_print = 55
+speed_layer_0 = =round(speed_print * 30 / 55)
speed_topbottom = =math.ceil(speed_print * 30 / 55)
speed_wall = =math.ceil(speed_print * 30 / 55)
diff --git a/resources/quality/ultimaker3/um3_aa0.4_PLA_Fast_Print.inst.cfg b/resources/quality/ultimaker3/um3_aa0.4_PLA_Fast_Print.inst.cfg
index 3f2aa1e652..c0b28ca6b7 100644
--- a/resources/quality/ultimaker3/um3_aa0.4_PLA_Fast_Print.inst.cfg
+++ b/resources/quality/ultimaker3/um3_aa0.4_PLA_Fast_Print.inst.cfg
@@ -17,6 +17,7 @@ machine_nozzle_heat_up_speed = 1.6
material_standby_temperature = 100
prime_tower_enable = False
speed_print = 80
+speed_layer_0 = =round(speed_print * 30 / 80)
speed_topbottom = =math.ceil(speed_print * 30 / 80)
speed_wall = =math.ceil(speed_print * 40 / 80)
speed_wall_0 = =math.ceil(speed_wall * 30 / 40)
diff --git a/resources/quality/ultimaker3/um3_aa0.4_PLA_High_Quality.inst.cfg b/resources/quality/ultimaker3/um3_aa0.4_PLA_High_Quality.inst.cfg
index f71c51e7ff..fe05a61beb 100644
--- a/resources/quality/ultimaker3/um3_aa0.4_PLA_High_Quality.inst.cfg
+++ b/resources/quality/ultimaker3/um3_aa0.4_PLA_High_Quality.inst.cfg
@@ -19,6 +19,7 @@ material_print_temperature = 195
material_standby_temperature = 100
skin_overlap = 10
speed_print = 60
+speed_layer_0 = =round(speed_print * 30 / 60)
speed_topbottom = =math.ceil(speed_print * 30 / 60)
speed_wall = =math.ceil(speed_print * 30 / 60)
top_bottom_thickness = 1
diff --git a/resources/themes/cura/icons/search.svg b/resources/themes/cura/icons/search.svg
new file mode 100644
index 0000000000..8272991300
--- /dev/null
+++ b/resources/themes/cura/icons/search.svg
@@ -0,0 +1,4 @@
+
diff --git a/resources/variants/ultimaker3_aa04.inst.cfg b/resources/variants/ultimaker3_aa04.inst.cfg
index c8d5b8aceb..dae256c990 100644
--- a/resources/variants/ultimaker3_aa04.inst.cfg
+++ b/resources/variants/ultimaker3_aa04.inst.cfg
@@ -12,15 +12,12 @@ brim_width = 7
machine_nozzle_cool_down_speed = 0.9
raft_acceleration = =acceleration_print
raft_airgap = 0.3
-raft_base_speed = =0.75 * raft_speed
raft_base_thickness = =resolveOrValue('layer_height_0') * 1.2
raft_interface_line_spacing = =raft_interface_line_width + 0.2
raft_interface_line_width = =line_width * 2
-raft_interface_speed = =raft_speed * 0.75
raft_interface_thickness = =layer_height * 1.5
raft_jerk = =jerk_print
raft_margin = 15
-raft_speed = =speed_print / 60 * 30
raft_surface_layers = 2
retraction_amount = 6.5
retraction_count_max = 25
diff --git a/resources/variants/ultimaker3_bb04.inst.cfg b/resources/variants/ultimaker3_bb04.inst.cfg
index 80e6b309a4..b813e8474d 100644
--- a/resources/variants/ultimaker3_bb04.inst.cfg
+++ b/resources/variants/ultimaker3_bb04.inst.cfg
@@ -12,9 +12,12 @@ cool_fan_speed_max = =cool_fan_speed
machine_nozzle_heat_up_speed = 1.5
material_print_temperature = 215
retraction_extrusion_window = =retraction_amount
+speed_layer_0 = 20
speed_wall_0 = =math.ceil(speed_wall * 25 / 30)
support_bottom_height = =layer_height * 2
support_bottom_stair_step_height = =layer_height
+raft_interface_speed = 20
+raft_base_speed = 20
support_infill_rate = 25
support_interface_enable = True
support_join_distance = 3
@@ -22,4 +25,5 @@ support_line_width = =round(line_width * 0.4 / 0.35, 2)
support_offset = 3
support_xy_distance = =wall_line_width_0 * 3
support_xy_distance_overhang = =wall_line_width_0 / 2
+raft_speed = 25
diff --git a/resources/variants/ultimaker3_extended_aa04.inst.cfg b/resources/variants/ultimaker3_extended_aa04.inst.cfg
index 24cbf04a26..6fa09c32ea 100644
--- a/resources/variants/ultimaker3_extended_aa04.inst.cfg
+++ b/resources/variants/ultimaker3_extended_aa04.inst.cfg
@@ -27,6 +27,7 @@ retraction_min_travel = 1.5
retraction_prime_speed = 25
skin_overlap = 15
speed_print = 70
+speed_layer_0 = =round(speed_print * 30 / 70)
speed_topbottom = =math.ceil(speed_print * 30 / 70)
speed_wall = =math.ceil(speed_print * 30 / 70)
support_angle = 60