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:
 body:
 - type: markdown
 - type: markdown
   attributes:
   attributes:
-    value: |
+    value: |      
        ### ๐Ÿ’ฅ Slicing Crash Analysis Tool ๐Ÿ’ฅ
        ### ๐Ÿ’ฅ Slicing Crash Analysis Tool ๐Ÿ’ฅ
        We are taking steps to analyze an increase in reported crashes more systematically. We'll need some help with that. ๐Ÿ˜‡
        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.
        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 ]
     needs: [ conan-recipe-version ]
     with:
     with:
       recipe_id_full: ${{ needs.conan-recipe-version.outputs.recipe_id_full }}
       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_cmd: 'pytest --junitxml=junit_cura.xml'
       unit_test_dir: 'tests'
       unit_test_dir: 'tests'
       conan_generator_dir: './venv/bin'
       conan_generator_dir: './venv/bin'

+ 10 - 189
UltiMaker-Cura.spec.jinja

@@ -55,7 +55,8 @@ exe = EXE(
     target_arch={{ target_arch }},
     target_arch={{ target_arch }},
     codesign_identity=os.getenv('CODESIGN_IDENTITY', None),
     codesign_identity=os.getenv('CODESIGN_IDENTITY', None),
     entitlements_file={{ entitlements_file }},
     entitlements_file={{ entitlements_file }},
-    icon={{ icon }}
+    icon={{ icon }},
+    contents_directory='.'
 )
 )
 
 
 coll = COLLECT(
 coll = COLLECT(
@@ -70,188 +71,7 @@ coll = COLLECT(
 )
 )
 
 
 {% if macos == true %}
 {% 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,
     coll,
     name='{{ display_name }}.app',
     name='{{ display_name }}.app',
     icon={{ icon }},
     icon={{ icon }},
@@ -271,9 +91,10 @@ app = UMBUNDLE(
                 'CFBundleURLSchemes': ['cura', 'slicer'],
                 'CFBundleURLSchemes': ['cura', 'slicer'],
         }],
         }],
         'CFBundleDocumentTypes': [{
         '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"
         - "sqlite3"
         - "trimesh"
         - "trimesh"
         - "win32ctypes"
         - "win32ctypes"
-        - "PyQt6"
         - "PyQt6.QtNetwork"
         - "PyQt6.QtNetwork"
         - "PyQt6.sip"
         - "PyQt6.sip"
         - "stl"
         - "stl"
@@ -160,6 +159,10 @@ pycharm_targets:
     module_name: Cura
     module_name: Cura
     name: pytest in TestGCodeListDecorator.py
     name: pytest in TestGCodeListDecorator.py
     script_name: tests/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
   - jinja_path: .run_templates/pycharm_cura_test.run.xml.jinja
     module_name: Cura
     module_name: Cura
     name: pytest in TestIntentManager.py
     name: pytest in TestIntentManager.py
@@ -188,6 +191,10 @@ pycharm_targets:
     module_name: Cura
     module_name: Cura
     name: pytest in TestPrintInformation.py
     name: pytest in TestPrintInformation.py
     script_name: tests/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
   - jinja_path: .run_templates/pycharm_cura_test.run.xml.jinja
     module_name: Cura
     module_name: Cura
     name: pytest in TestProfileRequirements.py
     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("cpython/3.10.4@ultimaker/stable")
         self.requires("clipper/6.4.2@ultimaker/stable")
         self.requires("clipper/6.4.2@ultimaker/stable")
         self.requires("openssl/3.2.0")
         self.requires("openssl/3.2.0")
+        self.requires("protobuf/3.21.12")
         self.requires("boost/1.82.0")
         self.requires("boost/1.82.0")
         self.requires("spdlog/1.12.0")
         self.requires("spdlog/1.12.0")
         self.requires("fmt/10.1.1")
         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
         # deselect currently selected nodes, and select the new nodes
         for node in Selection.getAllSelectedObjects():
         for node in Selection.getAllSelectedObjects():
             Selection.remove(node)
             Selection.remove(node)
+
+        numberOfFixedNodes = len(fixed_nodes)
         for node in nodes:
         for node in nodes:
+            numberOfFixedNodes += 1
+            node.printOrder = numberOfFixedNodes
             Selection.add(node)
             Selection.add(node)
 
 
     def _openUrl(self, url: QUrl) -> None:
     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.SidebarCustomMenuItemsModel import SidebarCustomMenuItemsModel
 from cura.Settings.SimpleModeSettingsManager import SimpleModeSettingsManager
 from cura.Settings.SimpleModeSettingsManager import SimpleModeSettingsManager
 from cura.TaskManagement.OnExitCallbackManager import OnExitCallbackManager
 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.AddPrinterPagesModel import AddPrinterPagesModel
 from cura.UI.MachineSettingsManager import MachineSettingsManager
 from cura.UI.MachineSettingsManager import MachineSettingsManager
 from cura.UI.ObjectsModel import ObjectsModel
 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.MachineListModel import MachineListModel
 from .Machines.Models.ActiveIntentQualitiesModel import ActiveIntentQualitiesModel
 from .Machines.Models.ActiveIntentQualitiesModel import ActiveIntentQualitiesModel
 from .Machines.Models.IntentSelectionModel import IntentSelectionModel
 from .Machines.Models.IntentSelectionModel import IntentSelectionModel
+from .PrintOrderManager import PrintOrderManager
 from .SingleInstance import SingleInstance
 from .SingleInstance import SingleInstance
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
@@ -179,6 +181,7 @@ class CuraApplication(QtApplication):
 
 
         # Variables set from CLI
         # Variables set from CLI
         self._files_to_open = []
         self._files_to_open = []
+        self._urls_to_open = []
         self._use_single_instance = False
         self._use_single_instance = False
 
 
         self._single_instance = None
         self._single_instance = None
@@ -186,7 +189,7 @@ class CuraApplication(QtApplication):
 
 
         self._cura_formula_functions = None  # type: Optional[CuraFormulaFunctions]
         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_container = None  # type: EmptyInstanceContainer
         self.empty_definition_changes_container = None  # type: EmptyInstanceContainer
         self.empty_definition_changes_container = None  # type: EmptyInstanceContainer
@@ -202,6 +205,7 @@ class CuraApplication(QtApplication):
         self._container_manager = None
         self._container_manager = None
 
 
         self._object_manager = None
         self._object_manager = None
+        self._print_order_manager = None
         self._extruders_model = None
         self._extruders_model = None
         self._extruders_model_with_optional = None
         self._extruders_model_with_optional = None
         self._build_plate_model = None
         self._build_plate_model = None
@@ -333,7 +337,7 @@ class CuraApplication(QtApplication):
         for filename in self._cli_args.file:
         for filename in self._cli_args.file:
             url = QUrl(filename)
             url = QUrl(filename)
             if url.scheme() in self._supported_url_schemes:
             if url.scheme() in self._supported_url_schemes:
-                self._open_url_queue.append(url)
+                self._urls_to_open.append(url)
             else:
             else:
                 self._files_to_open.append(os.path.abspath(filename))
                 self._files_to_open.append(os.path.abspath(filename))
 
 
@@ -352,11 +356,11 @@ class CuraApplication(QtApplication):
         self.__addAllEmptyContainers()
         self.__addAllEmptyContainers()
         self.__setLatestResouceVersionsForVersionUpgrade()
         self.__setLatestResouceVersionsForVersionUpgrade()
 
 
-        self._machine_action_manager = MachineActionManager.MachineActionManager(self)
+        self._machine_action_manager = MachineActionManager(self)
         self._machine_action_manager.initialize()
         self._machine_action_manager.initialize()
 
 
     def __sendCommandToSingleInstance(self):
     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 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.
         # 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)
             Resources.addExpectedDirNameInData(dir_name)
 
 
         app_root = os.path.abspath(os.path.join(os.path.dirname(sys.executable)))
         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"):
         if not hasattr(sys, "frozen"):
             cura_data_root = os.environ.get('CURA_DATA_ROOT', None)
             cura_data_root = os.environ.get('CURA_DATA_ROOT', None)
             if cura_data_root:
             if cura_data_root:
@@ -591,7 +601,9 @@ class CuraApplication(QtApplication):
         preferences.addPreference("mesh/scale_to_fit", False)
         preferences.addPreference("mesh/scale_to_fit", False)
         preferences.addPreference("mesh/scale_tiny_meshes", True)
         preferences.addPreference("mesh/scale_tiny_meshes", True)
         preferences.addPreference("cura/dialog_on_project_save", 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_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_profile_override", "always_ask")
         preferences.addPreference("cura/choice_on_open_project", "always_ask")
         preferences.addPreference("cura/choice_on_open_project", "always_ask")
         preferences.addPreference("cura/use_multi_build_plate", False)
         preferences.addPreference("cura/use_multi_build_plate", False)
@@ -607,6 +619,7 @@ class CuraApplication(QtApplication):
 
 
         preferences.addPreference("view/invert_zoom", False)
         preferences.addPreference("view/invert_zoom", False)
         preferences.addPreference("view/filter_current_build_plate", False)
         preferences.addPreference("view/filter_current_build_plate", False)
+        preferences.addPreference("view/navigation_style", "cura")
         preferences.addPreference("cura/sidebar_collapsed", False)
         preferences.addPreference("cura/sidebar_collapsed", False)
 
 
         preferences.addPreference("cura/favorite_materials", "")
         preferences.addPreference("cura/favorite_materials", "")
@@ -899,6 +912,7 @@ class CuraApplication(QtApplication):
         # initialize info objects
         # initialize info objects
         self._print_information = PrintInformation.PrintInformation(self)
         self._print_information = PrintInformation.PrintInformation(self)
         self._cura_actions = CuraActions.CuraActions(self)
         self._cura_actions = CuraActions.CuraActions(self)
+        self._print_order_manager = PrintOrderManager(self.getObjectsModel().getNodes)
         self.processEvents()
         self.processEvents()
         # Initialize setting visibility presets model.
         # Initialize setting visibility presets model.
         self._setting_visibility_presets_model = SettingVisibilityPresetsModel(self.getPreferences(), parent = self)
         self._setting_visibility_presets_model = SettingVisibilityPresetsModel(self.getPreferences(), parent = self)
@@ -956,6 +970,8 @@ class CuraApplication(QtApplication):
             self.callLater(self._openFile, file_name)
             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.
         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)
             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:
         for url in self._open_url_queue:
             self.callLater(self._openUrl, url)
             self.callLater(self._openUrl, url)
 
 
@@ -979,6 +995,7 @@ class CuraApplication(QtApplication):
             t.setEnabledAxis([ToolHandle.XAxis, ToolHandle.YAxis, ToolHandle.ZAxis])
             t.setEnabledAxis([ToolHandle.XAxis, ToolHandle.YAxis, ToolHandle.ZAxis])
 
 
         Selection.selectionChanged.connect(self.onSelectionChanged)
         Selection.selectionChanged.connect(self.onSelectionChanged)
+        self._print_order_manager.printOrderChanged.connect(self._onPrintOrderChanged)
 
 
         # Set default background color for scene
         # Set default background color for scene
         self.getRenderer().setBackgroundColor(QColor(245, 245, 245))
         self.getRenderer().setBackgroundColor(QColor(245, 245, 245))
@@ -1068,6 +1085,10 @@ class CuraApplication(QtApplication):
     def getTextManager(self, *args) -> "TextManager":
     def getTextManager(self, *args) -> "TextManager":
         return self._text_manager
         return self._text_manager
 
 
+    @pyqtSlot()
+    def setWorkplaceDropToBuildplate(self):
+        return self._physics.setAppAllModelDropDown()
+
     def getCuraFormulaFunctions(self, *args) -> "CuraFormulaFunctions":
     def getCuraFormulaFunctions(self, *args) -> "CuraFormulaFunctions":
         if self._cura_formula_functions is None:
         if self._cura_formula_functions is None:
             self._cura_formula_functions = CuraFormulaFunctions(self)
             self._cura_formula_functions = CuraFormulaFunctions(self)
@@ -1094,6 +1115,10 @@ class CuraApplication(QtApplication):
             self._object_manager = ObjectsModel(self)
             self._object_manager = ObjectsModel(self)
         return self._object_manager
         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)
     @pyqtSlot(result = QObject)
     def getExtrudersModel(self, *args) -> "ExtrudersModel":
     def getExtrudersModel(self, *args) -> "ExtrudersModel":
         if self._extruders_model is None:
         if self._extruders_model is None:
@@ -1119,6 +1144,16 @@ class CuraApplication(QtApplication):
             self._build_plate_model = BuildPlateModel(self)
             self._build_plate_model = BuildPlateModel(self)
         return self._build_plate_model
         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:
     def getCuraSceneController(self, *args) -> CuraSceneController:
         if self._cura_scene_controller is None:
         if self._cura_scene_controller is None:
             self._cura_scene_controller = CuraSceneController.createCuraSceneController()
             self._cura_scene_controller = CuraSceneController.createCuraSceneController()
@@ -1129,18 +1164,16 @@ class CuraApplication(QtApplication):
             self._setting_inheritance_manager = SettingInheritanceManager.createSettingInheritanceManager()
             self._setting_inheritance_manager = SettingInheritanceManager.createSettingInheritanceManager()
         return self._setting_inheritance_manager
         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
         """Get the machine action manager
 
 
         We ignore any *args given to this, as we also register the machine manager as qml singleton.
         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.
         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)
     @pyqtSlot(result = QObject)
     def getMaterialManagementModel(self) -> MaterialManagementModel:
     def getMaterialManagementModel(self) -> MaterialManagementModel:
@@ -1250,6 +1283,7 @@ class CuraApplication(QtApplication):
         self.processEvents()
         self.processEvents()
         engine.rootContext().setContextProperty("Printer", self)
         engine.rootContext().setContextProperty("Printer", self)
         engine.rootContext().setContextProperty("CuraApplication", self)
         engine.rootContext().setContextProperty("CuraApplication", self)
+        engine.rootContext().setContextProperty("PrintOrderManager", self._print_order_manager)
         engine.rootContext().setContextProperty("PrintInformation", self._print_information)
         engine.rootContext().setContextProperty("PrintInformation", self._print_information)
         engine.rootContext().setContextProperty("CuraActions", self._cura_actions)
         engine.rootContext().setContextProperty("CuraActions", self._cura_actions)
         engine.rootContext().setContextProperty("CuraSDKVersion", ApplicationMetadata.CuraSDKVersion)
         engine.rootContext().setContextProperty("CuraSDKVersion", ApplicationMetadata.CuraSDKVersion)
@@ -1264,7 +1298,7 @@ class CuraApplication(QtApplication):
         qmlRegisterSingletonType(IntentManager, "Cura", 1, 6, self.getIntentManager, "IntentManager")
         qmlRegisterSingletonType(IntentManager, "Cura", 1, 6, self.getIntentManager, "IntentManager")
         qmlRegisterSingletonType(SettingInheritanceManager, "Cura", 1, 0, self.getSettingInheritanceManager, "SettingInheritanceManager")
         qmlRegisterSingletonType(SettingInheritanceManager, "Cura", 1, 0, self.getSettingInheritanceManager, "SettingInheritanceManager")
         qmlRegisterSingletonType(SimpleModeSettingsManager, "Cura", 1, 0, self.getSimpleModeSettingsManagerWrapper, "SimpleModeSettingsManager")
         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()
         self.processEvents()
         qmlRegisterType(NetworkingUtil, "Cura", 1, 5, "NetworkingUtil")
         qmlRegisterType(NetworkingUtil, "Cura", 1, 5, "NetworkingUtil")
@@ -1745,8 +1779,12 @@ class CuraApplication(QtApplication):
             Selection.remove(node)
             Selection.remove(node)
         Selection.add(group_node)
         Selection.add(group_node)
 
 
+        all_nodes = self.getObjectsModel().getNodes()
+        PrintOrderManager.updatePrintOrdersAfterGroupOperation(all_nodes, group_node, selected_nodes)
+
     @pyqtSlot()
     @pyqtSlot()
     def ungroupSelected(self) -> None:
     def ungroupSelected(self) -> None:
+        all_nodes = self.getObjectsModel().getNodes()
         selected_objects = Selection.getAllSelectedObjects().copy()
         selected_objects = Selection.getAllSelectedObjects().copy()
         for node in selected_objects:
         for node in selected_objects:
             if node.callDecoration("isGroup"):
             if node.callDecoration("isGroup"):
@@ -1754,21 +1792,30 @@ class CuraApplication(QtApplication):
 
 
                 group_parent = node.getParent()
                 group_parent = node.getParent()
                 children = node.getChildren().copy()
                 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
                     # Set the parent of the children to the parent of the group-node
                     op.addOperation(SetParentOperation(child, group_parent))
                     op.addOperation(SetParentOperation(child, group_parent))
 
 
                     # Add all individual nodes to the selection
                     # Add all individual nodes to the selection
                     Selection.add(child)
                     Selection.add(child)
 
 
+                PrintOrderManager.updatePrintOrdersAfterUngroupOperation(all_nodes, node, children_to_ungroup)
                 op.push()
                 op.push()
                 # Note: The group removes itself from the scene once all its children have left it,
                 # Note: The group removes itself from the scene once all its children have left it,
                 # see GroupDecorator._onChildrenChanged
                 # 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]:
     def _createSplashScreen(self) -> Optional[CuraSplashScreen.CuraSplashScreen]:
         if self._is_headless:
         if self._is_headless:
             return None
             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")
 catalog = i18nCatalog("cura")
 TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
 TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
+REQUEST_TIMEOUT = 5 # Seconds
 
 
 
 
 class AuthorizationHelpers:
 class AuthorizationHelpers:
@@ -53,7 +54,8 @@ class AuthorizationHelpers:
             data = urllib.parse.urlencode(data).encode("UTF-8"),
             data = urllib.parse.urlencode(data).encode("UTF-8"),
             headers_dict = headers,
             headers_dict = headers,
             callback = lambda response: self.parseTokenResponse(response, callback),
             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:
     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"),
             data = urllib.parse.urlencode(data).encode("UTF-8"),
             headers_dict = headers,
             headers_dict = headers,
             callback = lambda response: self.parseTokenResponse(response, callback),
             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:
     def parseTokenResponse(self, token_response: QNetworkReply, callback: Callable[[AuthenticationResponse], None]) -> None:
@@ -122,7 +126,8 @@ class AuthorizationHelpers:
             check_token_url,
             check_token_url,
             headers_dict = headers,
             headers_dict = headers,
             callback = lambda reply: self._parseUserProfile(reply, success_callback, failed_callback),
             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:
     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.
 # Cura is released under the terms of the LGPLv3 or higher.
 
 
 import json
 import json
@@ -6,13 +6,14 @@ from datetime import datetime, timedelta
 from typing import Callable, Dict, Optional, TYPE_CHECKING, Union
 from typing import Callable, Dict, Optional, TYPE_CHECKING, Union
 from urllib.parse import urlencode, quote_plus
 from urllib.parse import urlencode, quote_plus
 
 
-from PyQt6.QtCore import QUrl
+from PyQt6.QtCore import QUrl, QTimer
 from PyQt6.QtGui import QDesktopServices
 from PyQt6.QtGui import QDesktopServices
 
 
 from UM.Logger import Logger
 from UM.Logger import Logger
 from UM.Message import Message
 from UM.Message import Message
 from UM.Signal import Signal
 from UM.Signal import Signal
 from UM.i18n import i18nCatalog
 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.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT
 from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
 from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
 from cura.OAuth2.Models import AuthenticationResponse, BaseModel
 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"
 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:
 class AuthorizationService:
     """The authorization service is responsible for handling the login flow, storing user credentials and providing
     """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.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):
     def _authChanged(self, logged_in):
         if logged_in and self._unable_to_get_data_message is not None:
         if logged_in and self._unable_to_get_data_message is not None:
             self._unable_to_get_data_message.hide()
             self._unable_to_get_data_message.hide()
@@ -167,16 +176,29 @@ class AuthorizationService:
             return
             return
 
 
         def process_auth_data(response: AuthenticationResponse) -> None:
         def process_auth_data(response: AuthenticationResponse) -> None:
+            self._currently_refreshing_token = False
+
             if response.success:
             if response.success:
+                self._refresh_token_retries = 0
                 self._storeAuthData(response)
                 self._storeAuthData(response)
+                HttpRequestManager.getInstance().setDelayRequests(False)
                 self.onAuthStateChanged.emit(logged_in = True)
                 self.onAuthStateChanged.emit(logged_in = True)
             else:
             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:
         if self._currently_refreshing_token:
             Logger.debug("Was already busy refreshing token. Do not start a new request.")
             Logger.debug("Was already busy refreshing token. Do not start a new request.")
             return
             return
+        HttpRequestManager.getInstance().setDelayRequests(True)
         self._currently_refreshing_token = True
         self._currently_refreshing_token = True
         self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token, process_auth_data)
         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