Browse Source

Merge branch 'main' into LimitXYAccelJerk

Remco Burema 1 year ago
parent
commit
5f6a8b1388

+ 1 - 1
.github/ISSUE_TEMPLATE/SlicingCrash.yaml

@@ -4,7 +4,7 @@ labels: ["Type: Bug", "Status: Triage", "Slicing Error :collision:"]
 body:
 - type: markdown
   attributes:
-    value: |
+    value: |      
        ### 💥 Slicing Crash Analysis Tool 💥
        We are taking steps to analyze an increase in reported crashes more systematically. We'll need some help with that. 😇
        Before filling out the report below, we want you to try a special Cura 5.7 Alpha.

+ 1 - 1
.github/workflows/unit-test.yml

@@ -55,7 +55,7 @@ jobs:
     needs: [ conan-recipe-version ]
     with:
       recipe_id_full: ${{ needs.conan-recipe-version.outputs.recipe_id_full }}
-      conan_extra_args: '-g VirtualPythonEnv -o cura:devtools=True -c tools.build:skip_test=False'
+      conan_extra_args: '-g VirtualPythonEnv -o cura:devtools=True -c tools.build:skip_test=False --options "*:enable_sentry=False"'
       unit_test_cmd: 'pytest --junitxml=junit_cura.xml'
       unit_test_dir: 'tests'
       conan_generator_dir: './venv/bin'

+ 10 - 189
UltiMaker-Cura.spec.jinja

@@ -55,7 +55,8 @@ exe = EXE(
     target_arch={{ target_arch }},
     codesign_identity=os.getenv('CODESIGN_IDENTITY', None),
     entitlements_file={{ entitlements_file }},
-    icon={{ icon }}
+    icon={{ icon }},
+    contents_directory='.'
 )
 
 coll = COLLECT(
@@ -70,188 +71,7 @@ coll = COLLECT(
 )
 
 {% if macos == true %}
-# PyInstaller seems to copy everything in the resource folder for the MacOS, this causes issues with codesigning and notarizing
-# The folder structure should adhere to the one specified in Table 2-5
-# https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html#//apple_ref/doc/uid/10000123i-CH101-SW1
-# The class below is basically ducktyping the BUNDLE class of PyInstaller and using our own `assemble` method for more fine-grain and specific
-# control. Some code of the method below is copied from:
-# https://github.com/pyinstaller/pyinstaller/blob/22d1d2a5378228744cc95f14904dae1664df32c4/PyInstaller/building/osx.py#L115
-#-----------------------------------------------------------------------------
-# Copyright (c) 2005-2022, PyInstaller Development Team.
-#
-# Distributed under the terms of the GNU General Public License (version 2
-# or later) with exception for distributing the bootloader.
-#
-# The full license is in the file COPYING.txt, distributed with this software.
-#
-# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
-#-----------------------------------------------------------------------------
-
-import plistlib
-import shutil
-import PyInstaller.utils.osx as osxutils
-from pathlib import Path
-from PyInstaller.building.osx import BUNDLE
-from PyInstaller.building.utils import (_check_path_overlap, _rmtree, add_suffix_to_extension, checkCache)
-from PyInstaller.building.datastruct import logger
-from PyInstaller.building.icon import normalize_icon_type
-
-
-class UMBUNDLE(BUNDLE):
-    def assemble(self):
-        from PyInstaller.config import CONF
-
-        if _check_path_overlap(self.name) and os.path.isdir(self.name):
-            _rmtree(self.name)
-        logger.info("Building BUNDLE %s", self.tocbasename)
-
-        # Create a minimal Mac bundle structure.
-        macos_path = Path(self.name, "Contents", "MacOS")
-        resources_path = Path(self.name, "Contents", "Resources")
-        frameworks_path = Path(self.name, "Contents", "Frameworks")
-        os.makedirs(macos_path)
-        os.makedirs(resources_path)
-        os.makedirs(frameworks_path)
-
-        # Makes sure the icon exists and attempts to convert to the proper format if applicable
-        self.icon = normalize_icon_type(self.icon, ("icns",), "icns", CONF["workpath"])
-
-        # Ensure icon path is absolute
-        self.icon = os.path.abspath(self.icon)
-
-        # Copy icns icon to Resources directory.
-        shutil.copy(self.icon, os.path.join(self.name, 'Contents', 'Resources'))
-
-        # Key/values for a minimal Info.plist file
-        info_plist_dict = {
-            "CFBundleDisplayName": self.appname,
-            "CFBundleName": self.appname,
-
-            # Required by 'codesign' utility.
-            # The value for CFBundleIdentifier is used as the default unique name of your program for Code Signing
-            # purposes. It even identifies the APP for access to restricted OS X areas like Keychain.
-            #
-            # The identifier used for signing must be globally unique. The usual form for this identifier is a
-            # hierarchical name in reverse DNS notation, starting with the toplevel domain, followed by the company
-            # name, followed by the department within the company, and ending with the product name. Usually in the
-            # form: com.mycompany.department.appname
-            # CLI option --osx-bundle-identifier sets this value.
-            "CFBundleIdentifier": self.bundle_identifier,
-            "CFBundleExecutable": os.path.basename(self.exename),
-            "CFBundleIconFile": os.path.basename(self.icon),
-            "CFBundleInfoDictionaryVersion": "6.0",
-            "CFBundlePackageType": "APPL",
-            "CFBundleVersionString": self.version,
-            "CFBundleShortVersionString": self.version,
-        }
-
-        # Set some default values. But they still can be overwritten by the user.
-        if self.console:
-            # Setting EXE console=True implies LSBackgroundOnly=True.
-            info_plist_dict['LSBackgroundOnly'] = True
-        else:
-            # Let's use high resolution by default.
-            info_plist_dict['NSHighResolutionCapable'] = True
-
-        # Merge info_plist settings from spec file
-        if isinstance(self.info_plist, dict) and self.info_plist:
-            info_plist_dict.update(self.info_plist)
-
-        plist_filename = os.path.join(self.name, "Contents", "Info.plist")
-        with open(plist_filename, "wb") as plist_fh:
-            plistlib.dump(info_plist_dict, plist_fh)
-
-        links = []
-        _QT_BASE_PATH = {'PySide2', 'PySide6', 'PyQt5', 'PyQt6', 'PySide6'}
-        for inm, fnm, typ in self.toc:
-            # Adjust name for extensions, if applicable
-            inm, fnm, typ = add_suffix_to_extension(inm, fnm, typ)
-            inm = Path(inm)
-            fnm = Path(fnm)
-            # Copy files from cache. This ensures that are used files with relative paths to dynamic library
-            # dependencies (@executable_path)
-            if typ in ('EXTENSION', 'BINARY') or (typ == 'DATA' and inm.suffix == '.so'):
-                if any(['.' in p for p in inm.parent.parts]):
-                    inm = Path(inm.name)
-                fnm = Path(checkCache(
-                    str(fnm),
-                    strip = self.strip,
-                    upx = self.upx,
-                    upx_exclude = self.upx_exclude,
-                    dist_nm = str(inm),
-                    target_arch = self.target_arch,
-                    codesign_identity = self.codesign_identity,
-                    entitlements_file = self.entitlements_file,
-                    strict_arch_validation = (typ == 'EXTENSION'),
-                ))
-                frame_dst = frameworks_path.joinpath(inm)
-                if not frame_dst.exists():
-                    if frame_dst.is_dir():
-                        os.makedirs(frame_dst, exist_ok = True)
-                    else:
-                        os.makedirs(frame_dst.parent, exist_ok = True)
-                shutil.copy(fnm, frame_dst, follow_symlinks = True)
-                macos_dst = macos_path.joinpath(inm)
-                if not macos_dst.exists():
-                    if macos_dst.is_dir():
-                        os.makedirs(macos_dst, exist_ok = True)
-                    else:
-                        os.makedirs(macos_dst.parent, exist_ok = True)
-
-                    # Create relative symlink to the framework
-                    symlink_to = Path(*[".." for p in macos_dst.relative_to(macos_path).parts], "Frameworks").joinpath(
-                        frame_dst.relative_to(frameworks_path))
-                    try:
-                        macos_dst.symlink_to(symlink_to)
-                    except FileExistsError:
-                        pass
-            else:
-                if typ == 'DATA':
-                    if any(['.' in p for p in inm.parent.parts]) or inm.suffix == '.so':
-                        # Skip info dist egg and some not needed folders in tcl and tk, since they all contain dots in their files
-                        logger.warning(f"Skipping DATA file {inm}")
-                        continue
-                    res_dst = resources_path.joinpath(inm)
-                    if not res_dst.exists():
-                        if res_dst.is_dir():
-                            os.makedirs(res_dst, exist_ok = True)
-                        else:
-                            os.makedirs(res_dst.parent, exist_ok = True)
-                    shutil.copy(fnm, res_dst, follow_symlinks = True)
-                    macos_dst = macos_path.joinpath(inm)
-                    if not macos_dst.exists():
-                        if macos_dst.is_dir():
-                            os.makedirs(macos_dst, exist_ok = True)
-                        else:
-                            os.makedirs(macos_dst.parent, exist_ok = True)
-
-                        # Create relative symlink to the resource
-                        symlink_to = Path(*[".." for p in macos_dst.relative_to(macos_path).parts], "Resources").joinpath(
-                            res_dst.relative_to(resources_path))
-                        try:
-                            macos_dst.symlink_to(symlink_to)
-                        except FileExistsError:
-                            pass
-                else:
-                    macos_dst = macos_path.joinpath(inm)
-                    if not macos_dst.exists():
-                        if macos_dst.is_dir():
-                            os.makedirs(macos_dst, exist_ok = True)
-                        else:
-                            os.makedirs(macos_dst.parent, exist_ok = True)
-                        shutil.copy(fnm, macos_dst, follow_symlinks = True)
-
-        # Sign the bundle
-        logger.info('Signing the BUNDLE...')
-        try:
-            osxutils.sign_binary(self.name, self.codesign_identity, self.entitlements_file, deep = True)
-        except Exception as e:
-            logger.warning(f"Error while signing the bundle: {e}")
-            logger.warning("You will need to sign the bundle manually!")
-
-        logger.info(f"Building BUNDLE {self.tocbasename} completed successfully.")
-
-app = UMBUNDLE(
+app = BUNDLE(
     coll,
     name='{{ display_name }}.app',
     icon={{ icon }},
@@ -271,9 +91,10 @@ app = UMBUNDLE(
                 'CFBundleURLSchemes': ['cura', 'slicer'],
         }],
         'CFBundleDocumentTypes': [{
-                'CFBundleTypeRole': 'Viewer',
-                'CFBundleTypeExtensions': ['*'],
-                'CFBundleTypeName': 'Model Files',
-            }]
-        },
-){% endif %}
+            'CFBundleTypeRole': 'Viewer',
+            'CFBundleTypeExtensions': ['stl', 'obj', '3mf', 'gcode', 'ufp'],
+            'CFBundleTypeName': 'Model Files',
+        }]
+    },
+)
+{% endif %}

+ 8 - 1
conandata.yml

@@ -118,7 +118,6 @@ pyinstaller:
         - "sqlite3"
         - "trimesh"
         - "win32ctypes"
-        - "PyQt6"
         - "PyQt6.QtNetwork"
         - "PyQt6.sip"
         - "stl"
@@ -160,6 +159,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
@@ -188,6 +191,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

+ 1 - 0
conanfile.py

@@ -350,6 +350,7 @@ class CuraConan(ConanFile):
         self.requires("cpython/3.10.4@ultimaker/stable")
         self.requires("clipper/6.4.2@ultimaker/stable")
         self.requires("openssl/3.2.0")
+        self.requires("protobuf/3.21.12")
         self.requires("boost/1.82.0")
         self.requires("spdlog/1.12.0")
         self.requires("fmt/10.1.1")

+ 4 - 0
cura/CuraActions.py

@@ -273,7 +273,11 @@ class CuraActions(QObject):
         # deselect currently selected nodes, and select the new nodes
         for node in Selection.getAllSelectedObjects():
             Selection.remove(node)
+
+        numberOfFixedNodes = len(fixed_nodes)
         for node in nodes:
+            numberOfFixedNodes += 1
+            node.printOrder = numberOfFixedNodes
             Selection.add(node)
 
     def _openUrl(self, url: QUrl) -> None:

+ 64 - 17
cura/CuraApplication.py

@@ -104,7 +104,8 @@ from cura.Settings.SettingInheritanceManager import SettingInheritanceManager
 from cura.Settings.SidebarCustomMenuItemsModel import SidebarCustomMenuItemsModel
 from cura.Settings.SimpleModeSettingsManager import SimpleModeSettingsManager
 from cura.TaskManagement.OnExitCallbackManager import OnExitCallbackManager
-from cura.UI import CuraSplashScreen, MachineActionManager, PrintInformation
+from cura.UI import CuraSplashScreen, PrintInformation
+from cura.UI.MachineActionManager import MachineActionManager
 from cura.UI.AddPrinterPagesModel import AddPrinterPagesModel
 from cura.UI.MachineSettingsManager import MachineSettingsManager
 from cura.UI.ObjectsModel import ObjectsModel
@@ -125,6 +126,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:
@@ -179,6 +181,7 @@ class CuraApplication(QtApplication):
 
         # Variables set from CLI
         self._files_to_open = []
+        self._urls_to_open = []
         self._use_single_instance = False
 
         self._single_instance = None
@@ -186,7 +189,7 @@ class CuraApplication(QtApplication):
 
         self._cura_formula_functions = None  # type: Optional[CuraFormulaFunctions]
 
-        self._machine_action_manager = None  # type: Optional[MachineActionManager.MachineActionManager]
+        self._machine_action_manager: Optional[MachineActionManager] = None
 
         self.empty_container = None  # type: EmptyInstanceContainer
         self.empty_definition_changes_container = None  # type: EmptyInstanceContainer
@@ -202,6 +205,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
@@ -333,7 +337,7 @@ class CuraApplication(QtApplication):
         for filename in self._cli_args.file:
             url = QUrl(filename)
             if url.scheme() in self._supported_url_schemes:
-                self._open_url_queue.append(url)
+                self._urls_to_open.append(url)
             else:
                 self._files_to_open.append(os.path.abspath(filename))
 
@@ -352,11 +356,11 @@ class CuraApplication(QtApplication):
         self.__addAllEmptyContainers()
         self.__setLatestResouceVersionsForVersionUpgrade()
 
-        self._machine_action_manager = MachineActionManager.MachineActionManager(self)
+        self._machine_action_manager = MachineActionManager(self)
         self._machine_action_manager.initialize()
 
     def __sendCommandToSingleInstance(self):
-        self._single_instance = SingleInstance(self, self._files_to_open)
+        self._single_instance = SingleInstance(self, self._files_to_open, self._urls_to_open)
 
         # If we use single instance, try to connect to the single instance server, send commands, and then exit.
         # If we cannot find an existing single instance server, this is the only instance, so just keep going.
@@ -373,9 +377,15 @@ class CuraApplication(QtApplication):
             Resources.addExpectedDirNameInData(dir_name)
 
         app_root = os.path.abspath(os.path.join(os.path.dirname(sys.executable)))
-        Resources.addSecureSearchPath(os.path.join(app_root, "share", "cura", "resources"))
 
-        Resources.addSecureSearchPath(os.path.join(self._app_install_dir, "share", "cura", "resources"))
+        if platform.system() == "Darwin":
+            Resources.addSecureSearchPath(os.path.join(app_root, "Resources", "share", "cura", "resources"))
+            Resources.addSecureSearchPath(
+                os.path.join(self._app_install_dir, "Resources", "share", "cura", "resources"))
+        else:
+            Resources.addSecureSearchPath(os.path.join(app_root, "share", "cura", "resources"))
+            Resources.addSecureSearchPath(os.path.join(self._app_install_dir, "share", "cura", "resources"))
+
         if not hasattr(sys, "frozen"):
             cura_data_root = os.environ.get('CURA_DATA_ROOT', None)
             if cura_data_root:
@@ -591,7 +601,9 @@ class CuraApplication(QtApplication):
         preferences.addPreference("mesh/scale_to_fit", False)
         preferences.addPreference("mesh/scale_tiny_meshes", True)
         preferences.addPreference("cura/dialog_on_project_save", True)
+        preferences.addPreference("cura/dialog_on_ucp_project_save", True)
         preferences.addPreference("cura/asked_dialog_on_project_save", False)
+        preferences.addPreference("cura/asked_dialog_on_ucp_project_save", False)
         preferences.addPreference("cura/choice_on_profile_override", "always_ask")
         preferences.addPreference("cura/choice_on_open_project", "always_ask")
         preferences.addPreference("cura/use_multi_build_plate", False)
@@ -607,6 +619,7 @@ class CuraApplication(QtApplication):
 
         preferences.addPreference("view/invert_zoom", False)
         preferences.addPreference("view/filter_current_build_plate", False)
+        preferences.addPreference("view/navigation_style", "cura")
         preferences.addPreference("cura/sidebar_collapsed", False)
 
         preferences.addPreference("cura/favorite_materials", "")
@@ -899,6 +912,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)
@@ -956,6 +970,8 @@ class CuraApplication(QtApplication):
             self.callLater(self._openFile, file_name)
         for file_name in self._open_file_queue:  # Open all the files that were queued up while plug-ins were loading.
             self.callLater(self._openFile, file_name)
+        for url in self._urls_to_open:
+            self.callLater(self._openUrl, url)
         for url in self._open_url_queue:
             self.callLater(self._openUrl, url)
 
@@ -979,6 +995,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))
@@ -1068,6 +1085,10 @@ class CuraApplication(QtApplication):
     def getTextManager(self, *args) -> "TextManager":
         return self._text_manager
 
+    @pyqtSlot()
+    def setWorkplaceDropToBuildplate(self):
+        return self._physics.setAppAllModelDropDown()
+
     def getCuraFormulaFunctions(self, *args) -> "CuraFormulaFunctions":
         if self._cura_formula_functions is None:
             self._cura_formula_functions = CuraFormulaFunctions(self)
@@ -1094,6 +1115,10 @@ class CuraApplication(QtApplication):
             self._object_manager = ObjectsModel(self)
         return self._object_manager
 
+    @pyqtSlot(str, result = "QVariantList")
+    def getSupportedActionMachineList(self, definition_id: str) -> List["MachineAction"]:
+        return self._machine_action_manager.getSupportedActions(self._machine_manager.getDefinitionByMachineId(definition_id))
+
     @pyqtSlot(result = QObject)
     def getExtrudersModel(self, *args) -> "ExtrudersModel":
         if self._extruders_model is None:
@@ -1119,6 +1144,16 @@ class CuraApplication(QtApplication):
             self._build_plate_model = BuildPlateModel(self)
         return self._build_plate_model
 
+    @pyqtSlot()
+    def exportUcp(self):
+        writer = self.getMeshFileHandler().getWriter("3MFWriter")
+
+        if writer is None:
+            Logger.warning("3mf writer is not enabled")
+            return
+
+        writer.exportUcp()
+
     def getCuraSceneController(self, *args) -> CuraSceneController:
         if self._cura_scene_controller is None:
             self._cura_scene_controller = CuraSceneController.createCuraSceneController()
@@ -1129,18 +1164,16 @@ class CuraApplication(QtApplication):
             self._setting_inheritance_manager = SettingInheritanceManager.createSettingInheritanceManager()
         return self._setting_inheritance_manager
 
-    def getMachineActionManager(self, *args: Any) -> MachineActionManager.MachineActionManager:
+    @pyqtSlot(result = QObject)
+    def getMachineActionManager(self, *args: Any) -> MachineActionManager:
         """Get the machine action manager
 
         We ignore any *args given to this, as we also register the machine manager as qml singleton.
         It wants to give this function an engine and script engine, but we don't care about that.
         """
 
-        return cast(MachineActionManager.MachineActionManager, self._machine_action_manager)
+        return  self._machine_action_manager
 
-    @pyqtSlot(result = QObject)
-    def getMachineActionManagerQml(self)-> MachineActionManager.MachineActionManager:
-        return cast(QObject, self._machine_action_manager)
 
     @pyqtSlot(result = QObject)
     def getMaterialManagementModel(self) -> MaterialManagementModel:
@@ -1250,6 +1283,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)
@@ -1264,7 +1298,7 @@ class CuraApplication(QtApplication):
         qmlRegisterSingletonType(IntentManager, "Cura", 1, 6, self.getIntentManager, "IntentManager")
         qmlRegisterSingletonType(SettingInheritanceManager, "Cura", 1, 0, self.getSettingInheritanceManager, "SettingInheritanceManager")
         qmlRegisterSingletonType(SimpleModeSettingsManager, "Cura", 1, 0, self.getSimpleModeSettingsManagerWrapper, "SimpleModeSettingsManager")
-        qmlRegisterSingletonType(MachineActionManager.MachineActionManager, "Cura", 1, 0, self.getMachineActionManagerWrapper, "MachineActionManager")
+        qmlRegisterSingletonType(MachineActionManager, "Cura", 1, 0, self.getMachineActionManagerWrapper, "MachineActionManager")
 
         self.processEvents()
         qmlRegisterType(NetworkingUtil, "Cura", 1, 5, "NetworkingUtil")
@@ -1745,8 +1779,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"):
@@ -1754,21 +1792,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

+ 8 - 3
cura/OAuth2/AuthorizationHelpers.py

@@ -16,6 +16,7 @@ from UM.TaskManagement.HttpRequestManager import HttpRequestManager  # To downlo
 
 catalog = i18nCatalog("cura")
 TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
+REQUEST_TIMEOUT = 5 # Seconds
 
 
 class AuthorizationHelpers:
@@ -53,7 +54,8 @@ class AuthorizationHelpers:
             data = urllib.parse.urlencode(data).encode("UTF-8"),
             headers_dict = headers,
             callback = lambda response: self.parseTokenResponse(response, callback),
-            error_callback = lambda response, _: self.parseTokenResponse(response, callback)
+            error_callback = lambda response, _: self.parseTokenResponse(response, callback),
+            timeout = REQUEST_TIMEOUT
         )
 
     def getAccessTokenUsingRefreshToken(self, refresh_token: str, callback: Callable[[AuthenticationResponse], None]) -> None:
@@ -77,7 +79,9 @@ class AuthorizationHelpers:
             data = urllib.parse.urlencode(data).encode("UTF-8"),
             headers_dict = headers,
             callback = lambda response: self.parseTokenResponse(response, callback),
-            error_callback = lambda response, _: self.parseTokenResponse(response, callback)
+            error_callback = lambda response, _: self.parseTokenResponse(response, callback),
+            urgent = True,
+            timeout = REQUEST_TIMEOUT
         )
 
     def parseTokenResponse(self, token_response: QNetworkReply, callback: Callable[[AuthenticationResponse], None]) -> None:
@@ -122,7 +126,8 @@ class AuthorizationHelpers:
             check_token_url,
             headers_dict = headers,
             callback = lambda reply: self._parseUserProfile(reply, success_callback, failed_callback),
-            error_callback = lambda _, _2: failed_callback() if failed_callback is not None else None
+            error_callback = lambda _, _2: failed_callback() if failed_callback is not None else None,
+            timeout = REQUEST_TIMEOUT
         )
 
     def _parseUserProfile(self, reply: QNetworkReply, success_callback: Optional[Callable[[UserProfile], None]], failed_callback: Optional[Callable[[], None]] = None) -> None:

+ 26 - 4
cura/OAuth2/AuthorizationService.py

@@ -1,4 +1,4 @@
-# Copyright (c) 2021 Ultimaker B.V.
+# Copyright (c) 2024 UltiMaker
 # Cura is released under the terms of the LGPLv3 or higher.
 
 import json
@@ -6,13 +6,14 @@ from datetime import datetime, timedelta
 from typing import Callable, Dict, Optional, TYPE_CHECKING, Union
 from urllib.parse import urlencode, quote_plus
 
-from PyQt6.QtCore import QUrl
+from PyQt6.QtCore import QUrl, QTimer
 from PyQt6.QtGui import QDesktopServices
 
 from UM.Logger import Logger
 from UM.Message import Message
 from UM.Signal import Signal
 from UM.i18n import i18nCatalog
+from UM.TaskManagement.HttpRequestManager import HttpRequestManager  # To download log-in tokens.
 from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT
 from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
 from cura.OAuth2.Models import AuthenticationResponse, BaseModel
@@ -25,6 +26,8 @@ if TYPE_CHECKING:
 
 MYCLOUD_LOGOFF_URL = "https://account.ultimaker.com/logoff?utm_source=cura&utm_medium=software&utm_campaign=change-account-before-adding-printers"
 
+REFRESH_TOKEN_MAX_RETRIES = 15
+REFRESH_TOKEN_RETRY_INTERVAL = 1000
 
 class AuthorizationService:
     """The authorization service is responsible for handling the login flow, storing user credentials and providing
@@ -57,6 +60,12 @@ class AuthorizationService:
 
         self.onAuthStateChanged.connect(self._authChanged)
 
+        self._refresh_token_retries = 0
+        self._refresh_token_retry_timer = QTimer()
+        self._refresh_token_retry_timer.setInterval(REFRESH_TOKEN_RETRY_INTERVAL)
+        self._refresh_token_retry_timer.setSingleShot(True)
+        self._refresh_token_retry_timer.timeout.connect(self.refreshAccessToken)
+
     def _authChanged(self, logged_in):
         if logged_in and self._unable_to_get_data_message is not None:
             self._unable_to_get_data_message.hide()
@@ -167,16 +176,29 @@ class AuthorizationService:
             return
 
         def process_auth_data(response: AuthenticationResponse) -> None:
+            self._currently_refreshing_token = False
+
             if response.success:
+                self._refresh_token_retries = 0
                 self._storeAuthData(response)
+                HttpRequestManager.getInstance().setDelayRequests(False)
                 self.onAuthStateChanged.emit(logged_in = True)
             else:
-                Logger.warning("Failed to get a new access token from the server.")
-                self.onAuthStateChanged.emit(logged_in = False)
+                if self._refresh_token_retries >= REFRESH_TOKEN_MAX_RETRIES:
+                    self._refresh_token_retries = 0
+                    Logger.warning("Failed to get a new access token from the server, giving up.")
+                    HttpRequestManager.getInstance().setDelayRequests(False)
+                    self.onAuthStateChanged.emit(logged_in = False)
+                else:
+                    # Retry a bit later, network may be offline right now and will hopefully be back soon
+                    Logger.warning("Failed to get a new access token from the server, retrying later.")
+                    self._refresh_token_retries += 1
+                    self._refresh_token_retry_timer.start()
 
         if self._currently_refreshing_token:
             Logger.debug("Was already busy refreshing token. Do not start a new request.")
             return
+        HttpRequestManager.getInstance().setDelayRequests(True)
         self._currently_refreshing_token = True
         self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token, process_auth_data)
 

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