Browse Source

Allow to set print sequence manually

alexandr-vladimirov 1 year ago
parent
commit
2b05a370ca

+ 8 - 0
conandata.yml

@@ -161,6 +161,10 @@ pycharm_targets:
     module_name: Cura
     name: pytest in TestGCodeListDecorator.py
     script_name: tests/TestGCodeListDecorator.py
+  - jinja_path: .run_templates/pycharm_cura_test.run.xml.jinja
+    module_name: Cura
+    name: pytest in TestHitChecker.py
+    script_name: tests/TestHitChecker.py
   - jinja_path: .run_templates/pycharm_cura_test.run.xml.jinja
     module_name: Cura
     name: pytest in TestIntentManager.py
@@ -189,6 +193,10 @@ pycharm_targets:
     module_name: Cura
     name: pytest in TestPrintInformation.py
     script_name: tests/TestPrintInformation.py
+  - jinja_path: .run_templates/pycharm_cura_test.run.xml.jinja
+    module_name: Cura
+    name: pytest in TestPrintOrderManager.py
+    script_name: tests/TestPrintOrderManager.py
   - jinja_path: .run_templates/pycharm_cura_test.run.xml.jinja
     module_name: Cura
     name: pytest in TestProfileRequirements.py

+ 22 - 4
cura/CuraApplication.py

@@ -125,6 +125,7 @@ from .Machines.Models.CompatibleMachineModel import CompatibleMachineModel
 from .Machines.Models.MachineListModel import MachineListModel
 from .Machines.Models.ActiveIntentQualitiesModel import ActiveIntentQualitiesModel
 from .Machines.Models.IntentSelectionModel import IntentSelectionModel
+from .PrintOrderManager import PrintOrderManager
 from .SingleInstance import SingleInstance
 
 if TYPE_CHECKING:
@@ -202,6 +203,7 @@ class CuraApplication(QtApplication):
         self._container_manager = None
 
         self._object_manager = None
+        self._print_order_manager = None
         self._extruders_model = None
         self._extruders_model_with_optional = None
         self._build_plate_model = None
@@ -899,6 +901,7 @@ class CuraApplication(QtApplication):
         # initialize info objects
         self._print_information = PrintInformation.PrintInformation(self)
         self._cura_actions = CuraActions.CuraActions(self)
+        self._print_order_manager = PrintOrderManager(self.getObjectsModel().getNodes)
         self.processEvents()
         # Initialize setting visibility presets model.
         self._setting_visibility_presets_model = SettingVisibilityPresetsModel(self.getPreferences(), parent = self)
@@ -979,6 +982,7 @@ class CuraApplication(QtApplication):
             t.setEnabledAxis([ToolHandle.XAxis, ToolHandle.YAxis, ToolHandle.ZAxis])
 
         Selection.selectionChanged.connect(self.onSelectionChanged)
+        self._print_order_manager.printOrderChanged.connect(self._onPrintOrderChanged)
 
         # Set default background color for scene
         self.getRenderer().setBackgroundColor(QColor(245, 245, 245))
@@ -1218,6 +1222,7 @@ class CuraApplication(QtApplication):
         self.processEvents()
         engine.rootContext().setContextProperty("Printer", self)
         engine.rootContext().setContextProperty("CuraApplication", self)
+        engine.rootContext().setContextProperty("PrintOrderManager", self._print_order_manager)
         engine.rootContext().setContextProperty("PrintInformation", self._print_information)
         engine.rootContext().setContextProperty("CuraActions", self._cura_actions)
         engine.rootContext().setContextProperty("CuraSDKVersion", ApplicationMetadata.CuraSDKVersion)
@@ -1715,8 +1720,12 @@ class CuraApplication(QtApplication):
             Selection.remove(node)
         Selection.add(group_node)
 
+        all_nodes = self.getObjectsModel().getNodes()
+        PrintOrderManager.updatePrintOrdersAfterGroupOperation(all_nodes, group_node, selected_nodes)
+
     @pyqtSlot()
     def ungroupSelected(self) -> None:
+        all_nodes = self.getObjectsModel().getNodes()
         selected_objects = Selection.getAllSelectedObjects().copy()
         for node in selected_objects:
             if node.callDecoration("isGroup"):
@@ -1724,21 +1733,30 @@ class CuraApplication(QtApplication):
 
                 group_parent = node.getParent()
                 children = node.getChildren().copy()
-                for child in children:
-                    # Ungroup only 1 level deep
-                    if child.getParent() != node:
-                        continue
 
+                # Ungroup only 1 level deep
+                children_to_ungroup = list(filter(lambda child: child.getParent() == node, children))
+                for child in children_to_ungroup:
                     # Set the parent of the children to the parent of the group-node
                     op.addOperation(SetParentOperation(child, group_parent))
 
                     # Add all individual nodes to the selection
                     Selection.add(child)
 
+                PrintOrderManager.updatePrintOrdersAfterUngroupOperation(all_nodes, node, children_to_ungroup)
                 op.push()
                 # Note: The group removes itself from the scene once all its children have left it,
                 # see GroupDecorator._onChildrenChanged
 
+    def _onPrintOrderChanged(self) -> None:
+        # update object list
+        scene = self.getController().getScene()
+        scene.sceneChanged.emit(scene.getRoot())
+
+        # reset if already was sliced
+        Application.getInstance().getBackend().needsSlicing()
+        Application.getInstance().getBackend().tickle()
+
     def _createSplashScreen(self) -> Optional[CuraSplashScreen.CuraSplashScreen]:
         if self._is_headless:
             return None

+ 88 - 0
cura/HitChecker.py

@@ -0,0 +1,88 @@
+from typing import List, Dict
+from cura.Scene.CuraSceneNode import CuraSceneNode
+
+
+class HitChecker:
+    """Checks if nodes can be printed without causing any collisions and interference"""
+
+    def __init__(self, nodes: List[CuraSceneNode]) -> None:
+        self._hit_map = self._buildHitMap(nodes)
+
+    def anyTwoNodesBlockEachOther(self, nodes: List[CuraSceneNode]) -> bool:
+        """Returns True if any 2 nodes block each other"""
+        for a in nodes:
+            for b in nodes:
+                if self._hit_map[a][b] and self._hit_map[b][a]:
+                    return True
+        return False
+
+    def canPrintBefore(self, node: CuraSceneNode, other_nodes: List[CuraSceneNode]) -> bool:
+        """Returns True if node doesn't block other_nodes and can be printed before them"""
+        no_hits = all(not self._hit_map[node][other_node] for other_node in other_nodes)
+        return no_hits
+
+    def canPrintAfter(self, node: CuraSceneNode, other_nodes: List[CuraSceneNode]) -> bool:
+        """Returns True if node doesn't hit other nodes and can be printed after them"""
+        no_hits = all(not self._hit_map[other_node][node] for other_node in other_nodes)
+        return no_hits
+
+    def calculateScore(self, a: CuraSceneNode, b: CuraSceneNode) -> int:
+        """Calculate score simply sums the number of other objects it 'blocks'
+
+        :param a: node
+        :param b: node
+        :return: sum of the number of other objects
+        """
+
+        score_a = sum(self._hit_map[a].values())
+        score_b = sum(self._hit_map[b].values())
+        return score_a - score_b
+
+    def canPrintNodesInProvidedOrder(self, ordered_nodes: List[CuraSceneNode]) -> bool:
+        """Returns True If nodes don't have any hits in provided order"""
+        for node_index, node in enumerate(ordered_nodes):
+            nodes_before = ordered_nodes[:node_index - 1] if node_index - 1 >= 0 else []
+            nodes_after = ordered_nodes[node_index + 1:] if node_index + 1 < len(ordered_nodes) else []
+            if not self.canPrintBefore(node, nodes_after) or not self.canPrintAfter(node, nodes_before):
+                return False
+        return True
+
+    @staticmethod
+    def _buildHitMap(nodes: List[CuraSceneNode]) -> Dict[CuraSceneNode, CuraSceneNode]:
+        """Pre-computes all hits between all objects
+
+        :nodes: nodes that need to be checked for collisions
+        :return: dictionary where hit_map[node1][node2] is False if there node1 can be printed before node2
+        """
+        hit_map = {j: {i: HitChecker._checkHit(j, i) for i in nodes} for j in nodes}
+        return hit_map
+
+    @staticmethod
+    def _checkHit(a: CuraSceneNode, b: CuraSceneNode) -> bool:
+        """Checks if a can be printed before b
+
+        :param a: node
+        :param b: node
+        :return: False if a can be printed before b
+        """
+
+        if a == b:
+            return False
+
+        a_hit_hull = a.callDecoration("getConvexHullBoundary")
+        b_hit_hull = b.callDecoration("getConvexHullHeadFull")
+        overlap = a_hit_hull.intersectsPolygon(b_hit_hull)
+
+        if overlap:
+            return True
+
+        # Adhesion areas must never overlap, regardless of printing order
+        # This would cause over-extrusion
+        a_hit_hull = a.callDecoration("getAdhesionArea")
+        b_hit_hull = b.callDecoration("getAdhesionArea")
+        overlap = a_hit_hull.intersectsPolygon(b_hit_hull)
+
+        if overlap:
+            return True
+        else:
+            return False

+ 32 - 83
cura/OneAtATimeIterator.py

@@ -7,6 +7,11 @@ from UM.Scene.Iterator import Iterator
 from UM.Scene.SceneNode import SceneNode
 from functools import cmp_to_key
 
+from cura.HitChecker import HitChecker
+from cura.PrintOrderManager import PrintOrderManager
+from cura.Scene.CuraSceneNode import CuraSceneNode
+
+
 class OneAtATimeIterator(Iterator.Iterator):
     """Iterator that returns a list of nodes in the order that they need to be printed
 
@@ -16,8 +21,6 @@ class OneAtATimeIterator(Iterator.Iterator):
 
     def __init__(self, scene_node) -> None:
         super().__init__(scene_node) # Call super to make multiple inheritance work.
-        self._hit_map = [[]]  # type: List[List[bool]]  # For each node, which other nodes this hits. A grid of booleans on which nodes hit which.
-        self._original_node_list = []  # type: List[SceneNode]  # The nodes that need to be checked for collisions.
 
     def _fillStack(self) -> None:
         """Fills the ``_node_stack`` with a list of scene nodes that need to be printed in order. """
@@ -38,104 +41,50 @@ class OneAtATimeIterator(Iterator.Iterator):
             self._node_stack = node_list[:]
             return
 
-        # Copy the list
-        self._original_node_list = node_list[:]
-
-        # Initialise the hit map (pre-compute all hits between all objects)
-        self._hit_map = [[self._checkHit(i, j) for i in node_list] for j in node_list]
+        hit_checker = HitChecker(node_list)
 
-        # Check if we have to files that block each other. If this is the case, there is no solution!
-        for a in range(0, len(node_list)):
-            for b in range(0, len(node_list)):
-                if a != b and self._hit_map[a][b] and self._hit_map[b][a]:
-                    return
+        if PrintOrderManager.isUserDefinedPrintOrderEnabled():
+            self._node_stack = self._getNodesOrderedByUser(hit_checker, node_list)
+        else:
+            self._node_stack = self._getNodesOrderedAutomatically(hit_checker, node_list)
+
+            # update print orders so that user can try to arrange the nodes automatically first
+            # and if result is not satisfactory he/she can switch to manual mode and change it
+            for index, node in enumerate(self._node_stack):
+                node.printOrder = index + 1
+
+    @staticmethod
+    def _getNodesOrderedByUser(hit_checker: HitChecker, node_list: List[CuraSceneNode]) -> List[CuraSceneNode]:
+        nodes_ordered_by_user = sorted(node_list, key=lambda n: n.printOrder)
+        if hit_checker.canPrintNodesInProvidedOrder(nodes_ordered_by_user):
+            return nodes_ordered_by_user
+        return []  # No solution
+
+    @staticmethod
+    def _getNodesOrderedAutomatically(hit_checker: HitChecker, node_list: List[CuraSceneNode]) -> List[CuraSceneNode]:
+        # Check if we have two files that block each other. If this is the case, there is no solution!
+        if hit_checker.anyTwoNodesBlockEachOther(node_list):
+            return []  # No solution
 
         # Sort the original list so that items that block the most other objects are at the beginning.
         # This does not decrease the worst case running time, but should improve it in most cases.
-        sorted(node_list, key = cmp_to_key(self._calculateScore))
+        sorted(node_list, key = cmp_to_key(hit_checker.calculateScore))
 
         todo_node_list = [_ObjectOrder([], node_list)]
         while len(todo_node_list) > 0:
             current = todo_node_list.pop()
             for node in current.todo:
                 # Check if the object can be placed with what we have and still allows for a solution in the future
-                if not self._checkHitMultiple(node, current.order) and not self._checkBlockMultiple(node, current.todo):
+                if hit_checker.canPrintAfter(node, current.order) and hit_checker.canPrintBefore(node, current.todo):
                     # We found a possible result. Create new todo & order list.
                     new_todo_list = current.todo[:]
                     new_todo_list.remove(node)
                     new_order = current.order[:] + [node]
                     if len(new_todo_list) == 0:
                         # We have no more nodes to check, so quit looking.
-                        self._node_stack = new_order
-                        return
+                        return new_order # Solution found!
                     todo_node_list.append(_ObjectOrder(new_order, new_todo_list))
-        self._node_stack = [] #No result found!
-
-
-    #  Check if first object can be printed before the provided list (using the hit map)
-    def _checkHitMultiple(self, node: SceneNode, other_nodes: List[SceneNode]) -> bool:
-        node_index = self._original_node_list.index(node)
-        for other_node in other_nodes:
-            other_node_index = self._original_node_list.index(other_node)
-            if self._hit_map[node_index][other_node_index]:
-                return True
-        return False
-
-    def _checkBlockMultiple(self, node: SceneNode, other_nodes: List[SceneNode]) -> bool:
-        """Check for a node whether it hits any of the other nodes.
-
-        :param node: The node to check whether it collides with the other nodes.
-        :param other_nodes: The nodes to check for collisions.
-        :return: returns collision between nodes
-        """
-
-        node_index = self._original_node_list.index(node)
-        for other_node in other_nodes:
-            other_node_index = self._original_node_list.index(other_node)
-            if self._hit_map[other_node_index][node_index] and node_index != other_node_index:
-                return True
-        return False
-
-    def _calculateScore(self, a: SceneNode, b: SceneNode) -> int:
-        """Calculate score simply sums the number of other objects it 'blocks'
-
-        :param a: node
-        :param b: node
-        :return: sum of the number of other objects
-        """
-
-        score_a = sum(self._hit_map[self._original_node_list.index(a)])
-        score_b = sum(self._hit_map[self._original_node_list.index(b)])
-        return score_a - score_b
-
-    def _checkHit(self, a: SceneNode, b: SceneNode) -> bool:
-        """Checks if a can be printed before b
-
-        :param a: node
-        :param b: node
-        :return: true if a can be printed before b
-        """
-
-        if a == b:
-            return False
-
-        a_hit_hull = a.callDecoration("getConvexHullBoundary")
-        b_hit_hull = b.callDecoration("getConvexHullHeadFull")
-        overlap = a_hit_hull.intersectsPolygon(b_hit_hull)
-
-        if overlap:
-            return True
-
-        # Adhesion areas must never overlap, regardless of printing order
-        # This would cause over-extrusion
-        a_hit_hull = a.callDecoration("getAdhesionArea")
-        b_hit_hull = b.callDecoration("getAdhesionArea")
-        overlap = a_hit_hull.intersectsPolygon(b_hit_hull)
-
-        if overlap:
-            return True
-        else:
-            return False
+        return []  # No result found!
 
 
 class _ObjectOrder:

+ 171 - 0
cura/PrintOrderManager.py

@@ -0,0 +1,171 @@
+from typing import List, Callable, Optional, Any
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, QObject, pyqtSlot
+from UM.Application import Application
+from UM.Scene.Selection import Selection
+
+from cura.Scene.CuraSceneNode import CuraSceneNode
+
+
+class PrintOrderManager(QObject):
+    """Allows to order the object list to set the print sequence manually"""
+
+    def __init__(self, get_nodes: Callable[[], List[CuraSceneNode]]) -> None:
+        super().__init__()
+        self._get_nodes = get_nodes
+        self._configureEvents()
+
+    _settingsChanged = pyqtSignal()
+    _uiActionsOutdated = pyqtSignal()
+    printOrderChanged = pyqtSignal()
+
+    @pyqtSlot()
+    def swapSelectedAndPreviousNodes(self) -> None:
+        selected_node, previous_node, next_node = self._getSelectedAndNeighborNodes()
+        self._swapPrintOrders(selected_node, previous_node)
+
+    @pyqtSlot()
+    def swapSelectedAndNextNodes(self) -> None:
+        selected_node, previous_node, next_node = self._getSelectedAndNeighborNodes()
+        self._swapPrintOrders(selected_node, next_node)
+
+    @pyqtProperty(str, notify=_uiActionsOutdated)
+    def previousNodeName(self) -> str:
+        selected_node, previous_node, next_node = self._getSelectedAndNeighborNodes()
+        return self._getNodeName(previous_node)
+
+    @pyqtProperty(str, notify=_uiActionsOutdated)
+    def nextNodeName(self) -> str:
+        selected_node, previous_node, next_node = self._getSelectedAndNeighborNodes()
+        return self._getNodeName(next_node)
+
+    @pyqtProperty(bool, notify=_uiActionsOutdated)
+    def shouldEnablePrintBeforeAction(self) -> bool:
+        selected_node, previous_node, next_node = self._getSelectedAndNeighborNodes()
+        can_swap_with_previous_node = selected_node is not None and previous_node is not None
+        return can_swap_with_previous_node
+
+    @pyqtProperty(bool, notify=_uiActionsOutdated)
+    def shouldEnablePrintAfterAction(self) -> bool:
+        selected_node, previous_node, next_node = self._getSelectedAndNeighborNodes()
+        can_swap_with_next_node = selected_node is not None and next_node is not None
+        return can_swap_with_next_node
+
+    @pyqtProperty(bool, notify=_settingsChanged)
+    def shouldShowEditPrintOrderActions(self) -> bool:
+        return PrintOrderManager.isUserDefinedPrintOrderEnabled()
+
+    @staticmethod
+    def isUserDefinedPrintOrderEnabled() -> bool:
+        stack = Application.getInstance().getGlobalContainerStack()
+        is_enabled = stack and \
+                     stack.getProperty("print_sequence", "value") == "one_at_a_time" and \
+                     stack.getProperty("user_defined_print_order_enabled", "value")
+        return is_enabled
+
+    @staticmethod
+    def initializePrintOrders(nodes: List[CuraSceneNode]) -> None:
+        """Just created (loaded from file) nodes have print order 0.
+
+         This method initializes print orders with max value to put nodes at the end of object list"""
+        max_print_order = max(map(lambda n: n.printOrder, nodes), default=0)
+        for node in nodes:
+            if node.printOrder == 0:
+                max_print_order += 1
+                node.printOrder = max_print_order
+
+    @staticmethod
+    def updatePrintOrdersAfterGroupOperation(
+            all_nodes: List[CuraSceneNode],
+            group_node: CuraSceneNode,
+            grouped_nodes: List[CuraSceneNode]
+    ) -> None:
+        group_node.printOrder = min(map(lambda n: n.printOrder, grouped_nodes))
+
+        all_nodes.append(group_node)
+        for node in grouped_nodes:
+            all_nodes.remove(node)
+
+        # reassign print orders so there won't be gaps like 1 2 5 6 7
+        sorted_nodes = sorted(all_nodes, key=lambda n: n.printOrder)
+        for i, node in enumerate(sorted_nodes):
+            node.printOrder = i + 1
+
+    @staticmethod
+    def updatePrintOrdersAfterUngroupOperation(
+            all_nodes: List[CuraSceneNode],
+            group_node: CuraSceneNode,
+            ungrouped_nodes: List[CuraSceneNode]
+    ) -> None:
+        all_nodes.remove(group_node)
+        nodes_to_update_print_order = filter(lambda n: n.printOrder > group_node.printOrder, all_nodes)
+        for node in nodes_to_update_print_order:
+            node.printOrder += len(ungrouped_nodes) - 1
+
+        for i, child in enumerate(ungrouped_nodes):
+            child.printOrder = group_node.printOrder + i
+            all_nodes.append(child)
+
+    def _swapPrintOrders(self, node1: CuraSceneNode, node2: CuraSceneNode) -> None:
+        if node1 and node2:
+            node1.printOrder, node2.printOrder = node2.printOrder, node1.printOrder  # swap print orders
+            self.printOrderChanged.emit()  # update object list first
+            self._uiActionsOutdated.emit()  # then update UI actions
+
+    def _getSelectedAndNeighborNodes(self
+                                     ) -> (Optional[CuraSceneNode], Optional[CuraSceneNode], Optional[CuraSceneNode]):
+        nodes = self._get_nodes()
+        ordered_nodes = sorted(nodes, key=lambda n: n.printOrder)
+        selected_node = PrintOrderManager._getSingleSelectedNode()
+        if selected_node and selected_node in ordered_nodes:
+            selected_node_index = ordered_nodes.index(selected_node)
+        else:
+            selected_node_index = None
+
+        if selected_node_index is not None and selected_node_index - 1 >= 0:
+            previous_node = ordered_nodes[selected_node_index - 1]
+        else:
+            previous_node = None
+
+        if selected_node_index is not None and selected_node_index + 1 < len(ordered_nodes):
+            next_node = ordered_nodes[selected_node_index + 1]
+        else:
+            next_node = None
+
+        return selected_node, previous_node, next_node
+
+    @staticmethod
+    def _getNodeName(node: CuraSceneNode, max_length: int = 30) -> str:
+        node_name = node.getName() if node else ""
+        truncated_node_name = node_name[:max_length]
+        return truncated_node_name
+
+    @staticmethod
+    def _getSingleSelectedNode() -> Optional[CuraSceneNode]:
+        if len(Selection.getAllSelectedObjects()) == 1:
+            selected_node = Selection.getSelectedObject(0)
+            return selected_node
+        return None
+
+    def _configureEvents(self) -> None:
+        Selection.selectionChanged.connect(self._onSelectionChanged)
+        self._global_stack = None
+        Application.getInstance().globalContainerStackChanged.connect(self._onGlobalStackChanged)
+        self._onGlobalStackChanged()
+
+    def _onGlobalStackChanged(self) -> None:
+        if self._global_stack:
+            self._global_stack.propertyChanged.disconnect(self._onSettingsChanged)
+            self._global_stack.containersChanged.disconnect(self._onSettingsChanged)
+
+        self._global_stack = Application.getInstance().getGlobalContainerStack()
+
+        if self._global_stack:
+            self._global_stack.propertyChanged.connect(self._onSettingsChanged)
+            self._global_stack.containersChanged.connect(self._onSettingsChanged)
+
+    def _onSettingsChanged(self, *args: Any) -> None:
+        self._settingsChanged.emit()
+
+    def _onSelectionChanged(self) -> None:
+        self._uiActionsOutdated.emit()

+ 12 - 0
cura/Scene/CuraSceneNode.py

@@ -25,10 +25,19 @@ class CuraSceneNode(SceneNode):
         if not no_setting_override:
             self.addDecorator(SettingOverrideDecorator())  # Now we always have a getActiveExtruderPosition, unless explicitly disabled
         self._outside_buildarea = False
+        self._print_order = 0
 
     def setOutsideBuildArea(self, new_value: bool) -> None:
         self._outside_buildarea = new_value
 
+    @property
+    def printOrder(self):
+        return self._print_order
+
+    @printOrder.setter
+    def printOrder(self, new_value):
+        self._print_order = new_value
+
     def isOutsideBuildArea(self) -> bool:
         return self._outside_buildarea or self.callDecoration("getBuildPlateNumber") < 0
 
@@ -157,3 +166,6 @@ class CuraSceneNode(SceneNode):
 
     def transformChanged(self) -> None:
         self._transformChanged()
+
+    def __repr__(self) -> str:
+        return "{print_order}. {name}".format(print_order = self._print_order, name = self.getName())

+ 17 - 2
cura/UI/ObjectsModel.py

@@ -14,6 +14,9 @@ from UM.Scene.SceneNode import SceneNode
 from UM.Scene.Selection import Selection
 from UM.i18n import i18nCatalog
 
+from cura.PrintOrderManager import PrintOrderManager
+from cura.Scene.CuraSceneNode import CuraSceneNode
+
 catalog = i18nCatalog("cura")
 
 
@@ -76,6 +79,9 @@ class ObjectsModel(ListModel):
             self._build_plate_number = nr
             self._update()
 
+    def getNodes(self) -> List[CuraSceneNode]:
+        return list(map(lambda n: n["node"], self.items))
+
     def _updateSceneDelayed(self, source) -> None:
         if not isinstance(source, Camera):
             self._update_timer.start()
@@ -175,6 +181,10 @@ class ObjectsModel(ListModel):
 
         all_nodes = self._renameNodes(name_to_node_info_dict)
 
+        user_defined_print_order_enabled = PrintOrderManager.isUserDefinedPrintOrderEnabled()
+        if user_defined_print_order_enabled:
+            PrintOrderManager.initializePrintOrders(all_nodes)
+
         for node in all_nodes:
             if hasattr(node, "isOutsideBuildArea"):
                 is_outside_build_area = node.isOutsideBuildArea()  # type: ignore
@@ -223,8 +233,13 @@ class ObjectsModel(ListModel):
                 # for anti overhang meshes and groups the extruder nr is irrelevant
                 extruder_number = -1
 
+            if not user_defined_print_order_enabled:
+                name = node.getName()
+            else:
+                name = "{print_order}. {name}".format(print_order = node.printOrder, name = node.getName())
+
             nodes.append({
-                "name": node.getName(),
+                "name": name,
                 "selected": Selection.isSelected(node),
                 "outside_build_area": is_outside_build_area,
                 "buildplate_number": node_build_plate_number,
@@ -234,5 +249,5 @@ class ObjectsModel(ListModel):
                 "node": node
             })
 
-        nodes = sorted(nodes, key=lambda n: n["name"])
+        nodes = sorted(nodes, key=lambda n: n["name"] if not user_defined_print_order_enabled else n["node"].printOrder)
         self.setItems(nodes)

+ 3 - 0
plugins/3MFReader/ThreeMFReader.py

@@ -177,6 +177,9 @@ class ThreeMFReader(MeshReader):
                     else:
                         Logger.log("w", "Unable to find extruder in position %s", setting_value)
                     continue
+                if key == "print_order":
+                    um_node.printOrder = int(setting_value)
+                    continue
                 if key in known_setting_keys:
                     setting_container.setProperty(key, "value", setting_value)
                 else:

+ 4 - 0
plugins/3MFWriter/ThreeMFWriter.py

@@ -20,6 +20,7 @@ from cura.CuraApplication import CuraApplication
 from cura.CuraPackageManager import CuraPackageManager
 from cura.Settings import CuraContainerStack
 from cura.Utils.Threading import call_on_qt_thread
+from cura.Scene.CuraSceneNode import CuraSceneNode
 from cura.Snapshot import Snapshot
 
 from PyQt6.QtCore import QBuffer
@@ -137,6 +138,9 @@ class ThreeMFWriter(MeshWriter):
             for key in changed_setting_keys:
                 savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value")))
 
+        if isinstance(um_node, CuraSceneNode):
+            savitar_node.setSetting("cura:print_order", str(um_node.printOrder))
+
         # Store the metadata.
         for key, value in um_node.metadata.items():
             savitar_node.setSetting(key, value)

+ 10 - 0
resources/definitions/fdmprinter.def.json

@@ -7060,6 +7060,16 @@
                     "settable_per_extruder": false,
                     "settable_per_meshgroup": false
                 },
+                "user_defined_print_order_enabled":
+                {
+                    "label": "Set Print Sequence Manually",
+                    "description": "Allows to order the object list to set the print sequence manually. First object from the list will be printed first.",
+                    "type": "bool",
+                    "default_value": false,
+                    "settable_per_mesh": false,
+                    "settable_per_extruder": false,
+                    "enabled": "print_sequence == 'one_at_a_time'"
+                },
                 "infill_mesh":
                 {
                     "label": "Infill Mesh",

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