diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 2ad8f90bfe..f88b77a022 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -45,7 +45,7 @@ env: STAGING: ${{ inputs.staging || false }} jobs: - windows-installer: + installer: uses: ultimaker/cura-workflows/.github/workflows/cura-installer-linux.yml@main with: cura_conan_version: ${{ inputs.cura_conan_version }} diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index d080fcc710..e909b9f839 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -49,7 +49,7 @@ env: STAGING: ${{ inputs.staging || false }} jobs: - windows-installer: + installer: uses: ultimaker/cura-workflows/.github/workflows/cura-installer-macos.yml@main with: cura_conan_version: ${{ inputs.cura_conan_version }} diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 0007936969..151935c3f3 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -45,7 +45,7 @@ env: STAGING: ${{ inputs.staging || false }} jobs: - windows-installer: + installer: uses: ultimaker/cura-workflows/.github/workflows/cura-installer-windows.yml@main with: cura_conan_version: ${{ inputs.cura_conan_version }} diff --git a/UltiMaker-Cura.spec.jinja b/UltiMaker-Cura.spec.jinja index 3d540d3b8f..cd939cf736 100644 --- a/UltiMaker-Cura.spec.jinja +++ b/UltiMaker-Cura.spec.jinja @@ -266,6 +266,10 @@ app = UMBUNDLE( 'CFBundlePackageType': 'APPL', 'CFBundleVersionString': {{ version }}, 'CFBundleShortVersionString': {{ short_version }}, + 'CFBundleURLTypes': [{ + 'CFBundleURLName': '{{ display_name }}', + 'CFBundleURLSchemes': ['cura', 'slicer'], + }], 'CFBundleDocumentTypes': [{ 'CFBundleTypeRole': 'Viewer', 'CFBundleTypeExtensions': ['*'], diff --git a/conandata.yml b/conandata.yml index c4dd1ce6de..a0a5a204ca 100644 --- a/conandata.yml +++ b/conandata.yml @@ -5,7 +5,7 @@ requirements: - "cura_binary_data/(latest)@ultimaker/testing" - "fdm_materials/(latest)@ultimaker/testing" - "curaengine_plugin_gradual_flow/(latest)@ultimaker/stable" - - "dulcificum/(latest)@ultimaker/testing" + - "dulcificum/latest@ultimaker/testing" - "pyarcus/5.3.0" - "pysavitar/5.3.0" - "pynest2d/5.3.0" diff --git a/conanfile.py b/conanfile.py index e9d06452f3..a3ca8f1c89 100644 --- a/conanfile.py +++ b/conanfile.py @@ -242,7 +242,7 @@ class CuraConan(ConanFile): self.output.warning(f"Source path for binary {binary['binary']} does not exist") continue - for bin in Path(src_path).glob(binary["binary"] + "*[.exe|.dll|.so|.dylib|.so.]*"): + for bin in Path(src_path).glob(binary["binary"] + "*[.exe|.dll|.so|.dylib|.so.|.pdb]*"): binaries.append((str(bin), binary["dst"])) for bin in Path(src_path).glob(binary["binary"]): binaries.append((str(bin), binary["dst"])) diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index 28dfd5e1c9..1ef2f63a9e 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -2,15 +2,18 @@ # Cura is released under the terms of the LGPLv3 or higher. import enum import os +import re import sys import tempfile import time import platform from pathlib import Path from typing import cast, TYPE_CHECKING, Optional, Callable, List, Any, Dict +import requests import numpy -from PyQt6.QtCore import QObject, QTimer, QUrl, pyqtSignal, pyqtProperty, QEvent, pyqtEnum, QCoreApplication +from PyQt6.QtCore import QObject, QTimer, QUrl, QUrlQuery, pyqtSignal, pyqtProperty, QEvent, pyqtEnum, QCoreApplication, \ + QByteArray from PyQt6.QtGui import QColor, QIcon from PyQt6.QtQml import qmlRegisterUncreatableType, qmlRegisterUncreatableMetaObject, qmlRegisterSingletonType, qmlRegisterType from PyQt6.QtWidgets import QMessageBox @@ -250,7 +253,7 @@ class CuraApplication(QtApplication): self._additional_components = {} # Components to add to certain areas in the interface self._open_file_queue = [] # A list of files to open (after the application has started) - + self._open_url_queue = [] # A list of urls to open (after the application has started) self._update_platform_activity_timer = None self._sidebar_custom_menu_items = [] # type: list # Keeps list of custom menu items for the side bar @@ -274,6 +277,8 @@ class CuraApplication(QtApplication): self._conan_installs = ApplicationMetadata.CONAN_INSTALLS self._python_installs = ApplicationMetadata.PYTHON_INSTALLS + self._supported_url_schemes: List[str] = ["cura", "slicer"] + @pyqtProperty(str, constant=True) def ultimakerCloudApiRootUrl(self) -> str: return UltimakerCloudConstants.CuraCloudAPIRoot @@ -326,7 +331,11 @@ class CuraApplication(QtApplication): assert not "This crash is triggered by the trigger_early_crash command line argument." for filename in self._cli_args.file: - self._files_to_open.append(os.path.abspath(filename)) + url = QUrl(filename) + if url.scheme() in self._supported_url_schemes: + self._open_url_queue.append(url) + else: + self._files_to_open.append(os.path.abspath(filename)) def initialize(self) -> None: self.__addExpectedResourceDirsAndSearchPaths() # Must be added before init of super @@ -947,6 +956,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._open_url_queue: + self.callLater(self._openUrl, url) initializationFinished = pyqtSignal() showAddPrintersUncancellableDialog = pyqtSignal() # Used to show the add printers dialog with a greyed background @@ -1156,9 +1167,15 @@ class CuraApplication(QtApplication): if event.type() == QEvent.Type.FileOpen: if self._plugins_loaded: - self._openFile(event.file()) + if event.file(): + self._openFile(event.file()) + if event.url(): + self._openUrl(event.url()) else: - self._open_file_queue.append(event.file()) + if event.file(): + self._open_file_queue.append(event.file()) + if event.url(): + self._open_url_queue.append(event.url()) if int(event.type()) == 20: # 'QEvent.Type.Quit' enum isn't there, even though it should be according to docs. # Once we're at this point, everything should have been flushed already (past OnExitCallbackManager). @@ -1542,7 +1559,7 @@ class CuraApplication(QtApplication): if not nodes: return - objects_in_filename = {} # type: Dict[str, List[CuraSceneNode]] + objects_in_filename: Dict[str, List[CuraSceneNode]] = {} for node in nodes: mesh_data = node.getMeshData() if mesh_data: @@ -1783,6 +1800,58 @@ class CuraApplication(QtApplication): def _openFile(self, filename): self.readLocalFile(QUrl.fromLocalFile(filename)) + def _openUrl(self, url: QUrl) -> None: + if url.scheme() not in self._supported_url_schemes: + # only handle cura:// and slicer:// urls schemes + return + + match url.host() + url.path(): + case "open" | "open/": + query = QUrlQuery(url.query()) + model_url = QUrl(query.queryItemValue("file", options=QUrl.ComponentFormattingOption.FullyDecoded)) + + def on_finish(response): + content_disposition_header_key = QByteArray("content-disposition".encode()) + + if not response.hasRawHeader(content_disposition_header_key): + Logger.log("w", "Could not find Content-Disposition header in response from {0}".format( + model_url.url())) + # Use the last part of the url as the filename, and assume it is an STL file + filename = model_url.path().split("/")[-1] + ".stl" + else: + # content_disposition is in the format + # ``` + # content_disposition attachment; "filename=[FILENAME]" + # ``` + # Use a regex to extract the filename + content_disposition = str(response.rawHeader(content_disposition_header_key).data(), + encoding='utf-8') + content_disposition_match = re.match(r'attachment; filename="(?P.*)"', + content_disposition) + assert content_disposition_match is not None + filename = content_disposition_match.group("filename") + + tmp = tempfile.NamedTemporaryFile(suffix=filename, delete=False) + with open(tmp.name, "wb") as f: + f.write(response.readAll()) + + self.readLocalFile(QUrl.fromLocalFile(tmp.name), add_to_recent_files=False) + + def on_error(*args, **kwargs): + Logger.log("w", "Could not download file from {0}".format(model_url.url())) + Message("Could not download file: " + str(model_url.url()), + title= "Loading Model failed", + message_type=Message.MessageType.ERROR).show() + return + + self.getHttpRequestManager().get( + model_url.url(), + callback=on_finish, + error_callback=on_error, + ) + case path: + Logger.log("w", "Unsupported url scheme path: {0}".format(path)) + def _addProfileReader(self, profile_reader): # TODO: Add the profile reader to the list of plug-ins that can be used when importing profiles. pass diff --git a/cura/LayerPolygon.py b/cura/LayerPolygon.py index e5fd307dc9..e772a8b78e 100644 --- a/cura/LayerPolygon.py +++ b/cura/LayerPolygon.py @@ -67,7 +67,7 @@ class LayerPolygon: # Buffering the colors shouldn't be necessary as it is not # re-used and can save a lot of memory usage. self._color_map = LayerPolygon.getColorMap() - self._colors = self._color_map[self._types] # type: numpy.ndarray + self._colors: numpy.ndarray = self._color_map[self._types] # When type is used as index returns true if type == LayerPolygon.InfillType # or type == LayerPolygon.SkinType @@ -75,8 +75,8 @@ class LayerPolygon: # Should be generated in better way, not hardcoded. self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype=bool) - self._build_cache_line_mesh_mask = None # type: Optional[numpy.ndarray] - self._build_cache_needed_points = None # type: Optional[numpy.ndarray] + self._build_cache_line_mesh_mask: Optional[numpy.ndarray] = None + self._build_cache_needed_points: Optional[numpy.ndarray] = None def buildCache(self) -> None: # For the line mesh we do not draw Infill or Jumps. Therefore those lines are filtered out. diff --git a/packaging/NSIS/Ultimaker-Cura.nsi.jinja b/packaging/NSIS/Ultimaker-Cura.nsi.jinja index 9996b24773..0a2ce0f517 100644 --- a/packaging/NSIS/Ultimaker-Cura.nsi.jinja +++ b/packaging/NSIS/Ultimaker-Cura.nsi.jinja @@ -144,6 +144,23 @@ SectionEnd ###################################################################### +Section UrlProtocol + +WriteRegStr HKCR "cura" "" "URL:cura" +WriteRegStr HKCR "cura" "URL Protocol" "" +WriteRegStr HKCR "cura\DefaultIcon" "" "$INSTDIR\${MAIN_APP_EXE},1" +WriteRegStr HKCR "cura\shell" "" "open" +WriteRegStr HKCR "cura\shell\open\command" "" '"$INSTDIR\${MAIN_APP_EXE}" "%1"' + +WriteRegStr HKCR "slicer" "" "URL:slicer" +WriteRegStr HKCR "slicer" "URL Protocol" "" +WriteRegStr HKCR "slicer\DefaultIcon" "" "$INSTDIR\${MAIN_APP_EXE},1" +WriteRegStr HKCR "slicer\shell" "" "open" +WriteRegStr HKCR "slicer\shell\open\command" "" '"$INSTDIR\${MAIN_APP_EXE}" "%1"' + +SectionEnd +###################################################################### + Section Uninstall ${INSTALL_TYPE}{% for files in mapped_out_paths.values() %}{% for file in files %} Delete "{{ file[1] }}"{% endfor %}{% endfor %}{% for rem_dir in rmdir_paths %} @@ -187,8 +204,13 @@ RmDir "$SMPROGRAMS\{{ app_name }}" !insertmacro APP_UNASSOCIATE "stl" "Cura.model" !insertmacro APP_UNASSOCIATE "3mf" "Cura.project" +; Unassociate file associations for 'cura' protocol +DeleteRegKey HKCR "cura" + +; Unassociate file associations for 'slicer' protocol +DeleteRegKey HKCR "slicer" + DeleteRegKey ${REG_ROOT} "${REG_APP_PATH}" DeleteRegKey ${REG_ROOT} "${UNINSTALL_PATH}" SectionEnd -###################################################################### diff --git a/packaging/msi/UltiMaker-Cura.wxs.jinja b/packaging/msi/UltiMaker-Cura.wxs.jinja index a183a97d5f..21f017c813 100644 --- a/packaging/msi/UltiMaker-Cura.wxs.jinja +++ b/packaging/msi/UltiMaker-Cura.wxs.jinja @@ -33,6 +33,21 @@ /> + + + + + {% if "Enterprise" in app_name %} @@ -144,11 +159,32 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/CuraEngineBackend/Cura.proto b/plugins/CuraEngineBackend/Cura.proto index 54c322fa0a..9593b83528 100644 --- a/plugins/CuraEngineBackend/Cura.proto +++ b/plugins/CuraEngineBackend/Cura.proto @@ -33,6 +33,8 @@ message Slice repeated Extruder extruders = 3; // The settings sent to each extruder object repeated SettingExtruder limit_to_extruder = 4; // From which stack the setting would inherit if not defined per object repeated EnginePlugin engine_plugins = 5; + string sentry_id = 6; // The anonymized Sentry user id that requested the slice + string cura_version = 7; // The version of Cura that requested the slice } message Extruder diff --git a/plugins/CuraEngineBackend/StartSliceJob.py b/plugins/CuraEngineBackend/StartSliceJob.py index a12e9e655d..fe7137150b 100644 --- a/plugins/CuraEngineBackend/StartSliceJob.py +++ b/plugins/CuraEngineBackend/StartSliceJob.py @@ -1,5 +1,7 @@ # Copyright (c) 2023 UltiMaker # Cura is released under the terms of the LGPLv3 or higher. +import uuid + import os import numpy @@ -30,6 +32,7 @@ from cura.CuraApplication import CuraApplication from cura.Scene.CuraSceneNode import CuraSceneNode from cura.OneAtATimeIterator import OneAtATimeIterator from cura.Settings.ExtruderManager import ExtruderManager +from cura.CuraVersion import CuraVersion NON_PRINTING_MESH_SETTINGS = ["anti_overhang_mesh", "infill_mesh", "cutting_mesh"] @@ -332,6 +335,11 @@ class StartSliceJob(Job): self._buildGlobalSettingsMessage(stack) self._buildGlobalInheritsStackMessage(stack) + user_id = uuid.getnode() # On all of Cura's supported platforms, this returns the MAC address which is pseudonymical information (!= anonymous). + user_id %= 2 ** 16 # So to make it anonymous, apply a bitmask selecting only the last 16 bits. This prevents it from being traceable to a specific user but still gives somewhat of an idea of whether it's just the same user hitting the same crash over and over again, or if it's widespread. + self._slice_message.sentry_id = "{user_id}" + self._slice_message.cura_version = CuraVersion + # Build messages for extruder stacks for extruder_stack in global_stack.extruderList: self._buildExtruderMessage(extruder_stack) diff --git a/plugins/SimulationView/SimulationPass.py b/plugins/SimulationView/SimulationPass.py index 1294b37db4..dd94d678ae 100644 --- a/plugins/SimulationView/SimulationPass.py +++ b/plugins/SimulationView/SimulationPass.py @@ -35,7 +35,7 @@ class SimulationPass(RenderPass): self._nozzle_shader = None self._disabled_shader = None self._old_current_layer = 0 - self._old_current_path = 0 + self._old_current_path: float = 0.0 self._switching_layers = True # Tracking whether the user is moving across layers (True) or across paths (False). If false, lower layers render as shadowy. self._gl = OpenGL.getInstance().getBindingsObject() self._scene = Application.getInstance().getController().getScene() @@ -139,7 +139,7 @@ class SimulationPass(RenderPass): continue # Render all layers below a certain number as line mesh instead of vertices. - if self._layer_view._current_layer_num > -1 and ((not self._layer_view._only_show_top_layers) or (not self._layer_view.getCompatibilityMode())): + if self._layer_view.getCurrentLayer() > -1 and ((not self._layer_view._only_show_top_layers) or (not self._layer_view.getCompatibilityMode())): start = 0 end = 0 element_counts = layer_data.getElementCounts() @@ -147,7 +147,7 @@ class SimulationPass(RenderPass): # In the current layer, we show just the indicated paths if layer == self._layer_view._current_layer_num: # We look for the position of the head, searching the point of the current path - index = self._layer_view._current_path_num + index = int(self._layer_view.getCurrentPath()) offset = 0 for polygon in layer_data.getLayer(layer).polygons: # The size indicates all values in the two-dimension array, and the second dimension is @@ -157,23 +157,33 @@ class SimulationPass(RenderPass): offset = 1 # This is to avoid the first point when there is more than one polygon, since has the same value as the last point in the previous polygon continue # The head position is calculated and translated - head_position = Vector(polygon.data[index+offset][0], polygon.data[index+offset][1], polygon.data[index+offset][2]) + node.getWorldPosition() + ratio = self._layer_view.getCurrentPath() - index + pos_a = Vector(polygon.data[index + offset][0], polygon.data[index + offset][1], + polygon.data[index + offset][2]) + if ratio <= 0.0001 or index + offset < len(polygon.data): + head_position = pos_a + node.getWorldPosition() + else: + pos_b = Vector(polygon.data[index + offset + 1][0], + polygon.data[index + offset + 1][1], + polygon.data[index + offset + 1][2]) + vec = pos_a * (1.0 - ratio) + pos_b * ratio + head_position = vec + node.getWorldPosition() break break - if self._layer_view._minimum_layer_num > layer: + if self._layer_view.getMinimumLayer() > layer: start += element_counts[layer] end += element_counts[layer] # Calculate the range of paths in the last layer current_layer_start = end - current_layer_end = end + self._layer_view._current_path_num * 2 # Because each point is used twice + current_layer_end = end + int( self._layer_view.getCurrentPath()) * 2 # Because each point is used twice # This uses glDrawRangeElements internally to only draw a certain range of lines. # All the layers but the current selected layer are rendered first - if self._old_current_path != self._layer_view._current_path_num: + if self._old_current_path != self._layer_view.getCurrentPath(): self._current_shader = self._layer_shadow_shader self._switching_layers = False - if not self._layer_view.isSimulationRunning() and self._old_current_layer != self._layer_view._current_layer_num: + if not self._layer_view.isSimulationRunning() and self._old_current_layer != self._layer_view.getCurrentLayer(): self._current_shader = self._layer_shader self._switching_layers = True @@ -193,8 +203,8 @@ class SimulationPass(RenderPass): current_layer_batch.addItem(node.getWorldTransformation(), layer_data) current_layer_batch.render(self._scene.getActiveCamera()) - self._old_current_layer = self._layer_view._current_layer_num - self._old_current_path = self._layer_view._current_path_num + self._old_current_layer = self._layer_view.getCurrentLayer() + self._old_current_path = self._layer_view.getCurrentPath() # Create a new batch that is not range-limited batch = RenderBatch(self._layer_shader, type = RenderBatch.RenderType.Solid) @@ -230,4 +240,4 @@ class SimulationPass(RenderPass): if changed_object.callDecoration("getLayerData"): # Any layer data has changed. self._switching_layers = True self._old_current_layer = 0 - self._old_current_path = 0 + self._old_current_path = 0.0 diff --git a/plugins/SimulationView/SimulationView.py b/plugins/SimulationView/SimulationView.py index a659a6de97..337879475b 100644 --- a/plugins/SimulationView/SimulationView.py +++ b/plugins/SimulationView/SimulationView.py @@ -1,6 +1,5 @@ # Copyright (c) 2021 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. - import sys from PyQt6.QtCore import Qt @@ -58,6 +57,7 @@ class SimulationView(CuraView): LAYER_VIEW_TYPE_LINE_TYPE = 1 LAYER_VIEW_TYPE_FEEDRATE = 2 LAYER_VIEW_TYPE_THICKNESS = 3 + SIMULATION_FACTOR = 3 _no_layers_warning_preference = "view/no_layers_warning" @@ -74,21 +74,20 @@ class SimulationView(CuraView): self._old_max_layers = 0 self._max_paths = 0 - self._current_path_num = 0 + self._current_path_num: float = 0.0 + self._current_time = 0.0 self._minimum_path_num = 0 self.currentLayerNumChanged.connect(self._onCurrentLayerNumChanged) - self._current_feedrates = {} - self._lengths_of_polyline ={} self._busy = False self._simulation_running = False - self._ghost_shader = None # type: Optional["ShaderProgram"] - self._layer_pass = None # type: Optional[SimulationPass] - self._composite_pass = None # type: Optional[CompositePass] - self._old_layer_bindings = None # type: Optional[List[str]] - self._simulationview_composite_shader = None # type: Optional["ShaderProgram"] - self._old_composite_shader = None # type: Optional["ShaderProgram"] + self._ghost_shader: Optional["ShaderProgram"] = None + self._layer_pass: Optional[SimulationPass] = None + self._composite_pass: Optional[CompositePass] = None + self._old_layer_bindings: Optional[List[str]] = None + self._simulationview_composite_shader: Optional["ShaderProgram"] = None + self._old_composite_shader: Optional["ShaderProgram"] = None self._max_feedrate = sys.float_info.min self._min_feedrate = sys.float_info.max @@ -98,14 +97,15 @@ class SimulationView(CuraView): self._min_line_width = sys.float_info.max self._min_flow_rate = sys.float_info.max self._max_flow_rate = sys.float_info.min + self._cumulative_line_duration = {} - self._global_container_stack = None # type: Optional[ContainerStack] + self._global_container_stack: Optional[ContainerStack] = None self._proxy = None self._resetSettings() self._legend_items = None self._show_travel_moves = False - self._nozzle_node = None # type: Optional[NozzleNode] + self._nozzle_node: Optional[NozzleNode] = None Application.getInstance().getPreferences().addPreference("view/top_layer_count", 5) Application.getInstance().getPreferences().addPreference("view/only_show_top_layers", False) @@ -127,13 +127,12 @@ class SimulationView(CuraView): self._only_show_top_layers = bool(Application.getInstance().getPreferences().getValue("view/only_show_top_layers")) self._compatibility_mode = self._evaluateCompatibilityMode() - self._slice_first_warning_message = Message(catalog.i18nc("@info:status", - "Nothing is shown because you need to slice first."), - title = catalog.i18nc("@info:title", "No layers to show"), - option_text = catalog.i18nc("@info:option_text", - "Do not show this message again"), - option_state = False, - message_type = Message.MessageType.WARNING) + self._slice_first_warning_message = Message(catalog.i18nc("@info:status", "Nothing is shown because you need to slice first."), + title=catalog.i18nc("@info:title", "No layers to show"), + option_text=catalog.i18nc("@info:option_text", + "Do not show this message again"), + option_state=False, + message_type=Message.MessageType.WARNING) self._slice_first_warning_message.optionToggled.connect(self._onDontAskMeAgain) CuraApplication.getInstance().getPreferences().addPreference(self._no_layers_warning_preference, True) @@ -189,9 +188,85 @@ class SimulationView(CuraView): def getMaxLayers(self) -> int: return self._max_layers - def getCurrentPath(self) -> int: + def getCurrentPath(self) -> float: return self._current_path_num + def setTime(self, time: float) -> None: + cumulative_line_duration = self.cumulativeLineDuration() + if len(cumulative_line_duration) > 0: + self._current_time = time + left_i = 0 + right_i = self._max_paths - 1 + total_duration = cumulative_line_duration[-1] + # make an educated guess about where to start + i = int(right_i * max(0.0, min(1.0, self._current_time / total_duration))) + # binary search for the correct path + while left_i < right_i: + if cumulative_line_duration[i] <= self._current_time: + left_i = i + 1 + else: + right_i = i + i = int((left_i + right_i) / 2) + + left_value = cumulative_line_duration[i - 1] if i > 0 else 0.0 + right_value = cumulative_line_duration[i] + + assert (left_value <= self._current_time <= right_value) + + fractional_value = (self._current_time - left_value) / (right_value - left_value) + + self.setPath(i + fractional_value) + + def advanceTime(self, time_increase: float) -> bool: + """ + Advance the time by the given amount. + + :param time_increase: The amount of time to advance (in seconds). + :return: True if the time was advanced, False if the end of the simulation was reached. + """ + total_duration = 0.0 + if len(self.cumulativeLineDuration()) > 0: + total_duration = self.cumulativeLineDuration()[-1] + + if self._current_time + time_increase > total_duration: + # If we have reached the end of the simulation, go to the next layer. + if self.getCurrentLayer() == self.getMaxLayers(): + # If we are already at the last layer, go to the first layer. + self.setTime(total_duration) + return False + + # advance to the next layer, and reset the time + self.setLayer(self.getCurrentLayer() + 1) + self.setTime(0.0) + else: + self.setTime(self._current_time + time_increase) + return True + + def cumulativeLineDuration(self) -> List[float]: + # Make sure _cumulative_line_duration is initialized properly + if self.getCurrentLayer() not in self._cumulative_line_duration: + #clear cache + self._cumulative_line_duration = {} + self._cumulative_line_duration[self.getCurrentLayer()] = [] + total_duration = 0.0 + polylines = self.getLayerData() + if polylines is not None: + for polyline in polylines.polygons: + for line_duration in list((polyline.lineLengths / polyline.lineFeedrates)[0]): + total_duration += line_duration / SimulationView.SIMULATION_FACTOR + self._cumulative_line_duration[self.getCurrentLayer()].append(total_duration) + + return self._cumulative_line_duration[self.getCurrentLayer()] + + def getLayerData(self) -> Optional["LayerData"]: + scene = self.getController().getScene() + for node in DepthFirstIterator(scene.getRoot()): # type: ignore + layer_data = node.callDecoration("getLayerData") + if not layer_data: + continue + return layer_data.getLayer(self.getCurrentLayer()) + return None + def getMinimumPath(self) -> int: return self._minimum_path_num @@ -279,7 +354,7 @@ class SimulationView(CuraView): self._startUpdateTopLayers() self.currentLayerNumChanged.emit() - def setPath(self, value: int) -> None: + def setPath(self, value: float) -> None: """ Set the upper end of the range of visible paths on the current layer. @@ -289,6 +364,9 @@ class SimulationView(CuraView): if self._current_path_num != value: self._current_path_num = min(max(value, 0), self._max_paths) self._minimum_path_num = min(self._minimum_path_num, self._current_path_num) + # update _current time when the path is changed by user + if self._current_path_num < self._max_paths and round(self._current_path_num)== self._current_path_num: + self._current_time = self.cumulativeLineDuration()[int(self._current_path_num)] self._startUpdateTopLayers() self.currentPathNumChanged.emit() @@ -402,15 +480,6 @@ class SimulationView(CuraView): def getMaxFeedrate(self) -> float: return self._max_feedrate - def getSimulationTime(self, currentIndex) -> float: - try: - return (self._lengths_of_polyline[self._current_layer_num][currentIndex] / self._current_feedrates[self._current_layer_num][currentIndex])[0] - - except: - # In case of change in layers, currentIndex comes one more than the items in the lengths_of_polyline - # We give 1 second time for layer change - return 1.0 - def getMinThickness(self) -> float: if abs(self._min_thickness - sys.float_info.max) < 10: # Some lenience due to floating point rounding. return 0.0 # If it's still max-float, there are no measurements. Use 0 then. @@ -503,6 +572,7 @@ class SimulationView(CuraView): self._max_thickness = sys.float_info.min self._min_flow_rate = sys.float_info.max self._max_flow_rate = sys.float_info.min + self._cumulative_line_duration = {} # The colour scheme is only influenced by the visible lines, so filter the lines by if they should be visible. visible_line_types = [] @@ -535,10 +605,8 @@ class SimulationView(CuraView): visible_indicies_with_extrusion = numpy.where(numpy.isin(polyline.types, visible_line_types_with_extrusion))[0] if visible_indices.size == 0: # No items to take maximum or minimum of. continue - self._lengths_of_polyline[layer_index] = polyline.lineLengths visible_feedrates = numpy.take(polyline.lineFeedrates, visible_indices) visible_feedrates_with_extrusion = numpy.take(polyline.lineFeedrates, visible_indicies_with_extrusion) - self._current_feedrates[layer_index] = polyline.lineFeedrates visible_linewidths = numpy.take(polyline.lineWidths, visible_indices) visible_linewidths_with_extrusion = numpy.take(polyline.lineWidths, visible_indicies_with_extrusion) visible_thicknesses = numpy.take(polyline.lineThicknesses, visible_indices) diff --git a/plugins/SimulationView/SimulationViewMainComponent.qml b/plugins/SimulationView/SimulationViewMainComponent.qml index 216095c15c..d9e7a95bc1 100644 --- a/plugins/SimulationView/SimulationViewMainComponent.qml +++ b/plugins/SimulationView/SimulationViewMainComponent.qml @@ -127,6 +127,7 @@ Item function resumeSimulation() { UM.SimulationView.setSimulationRunning(true) + UM.SimulationView.setCurrentPath(UM.SimulationView.currentPath) simulationTimer.start() layerSlider.manuallyChanged = false pathSlider.manuallyChanged = false @@ -136,54 +137,19 @@ Item Timer { id: simulationTimer - interval: UM.SimulationView.simulationTime + interval: 1000 / 15 running: false repeat: true onTriggered: { - var currentPath = UM.SimulationView.currentPath - var numPaths = UM.SimulationView.numPaths - var currentLayer = UM.SimulationView.currentLayer - var numLayers = UM.SimulationView.numLayers - - // When the user plays the simulation, if the path slider is at the end of this layer, we start - // the simulation at the beginning of the current layer. - if (!isSimulationPlaying) - { - if (currentPath >= numPaths) - { - UM.SimulationView.setCurrentPath(0) - } - else - { - UM.SimulationView.setCurrentPath(currentPath + 1) - } - } - // If the simulation is already playing and we reach the end of a layer, then it automatically - // starts at the beginning of the next layer. - else - { - if (currentPath >= numPaths) - { - // At the end of the model, the simulation stops - if (currentLayer >= numLayers) - { - playButton.pauseSimulation() - } - else - { - UM.SimulationView.setCurrentLayer(currentLayer + 1) - UM.SimulationView.setCurrentPath(0) - } - } - else - { - UM.SimulationView.setCurrentPath(currentPath + 1) - } + // divide by 1000 to account for ms to s conversion + const advance_time = simulationTimer.interval / 1000.0; + if (!UM.SimulationView.advanceTime(advance_time)) { + playButton.pauseSimulation(); } // The status must be set here instead of in the resumeSimulation function otherwise it won't work // correctly, because part of the logic is in this trigger function. - isSimulationPlaying = true + isSimulationPlaying = true; } } diff --git a/plugins/SimulationView/SimulationViewProxy.py b/plugins/SimulationView/SimulationViewProxy.py index 3bf2ed6f49..bf449a99d1 100644 --- a/plugins/SimulationView/SimulationViewProxy.py +++ b/plugins/SimulationView/SimulationViewProxy.py @@ -11,11 +11,6 @@ if TYPE_CHECKING: class SimulationViewProxy(QObject): - - S_TO_MS = 1000 - SPEED_OF_SIMULATION = 10 - FACTOR = S_TO_MS/SPEED_OF_SIMULATION - def __init__(self, simulation_view: "SimulationView", parent=None) -> None: super().__init__(parent) self._simulation_view = simulation_view @@ -55,17 +50,13 @@ class SimulationViewProxy(QObject): def numPaths(self): return self._simulation_view.getMaxPaths() - @pyqtProperty(int, notify=currentPathChanged) + @pyqtProperty(float, notify=currentPathChanged) def currentPath(self): return self._simulation_view.getCurrentPath() - @pyqtProperty(int, notify=currentPathChanged) - def simulationTime(self): - # Extracts the currents paths simulation time (in seconds) for the current path from the dict of simulation time of the current layer. - # We multiply the time with 100 to make it to ms from s.(Should be 1000 in real time). This scaling makes the simulation time 10x faster than the real time. - simulationTimeOfpath = self._simulation_view.getSimulationTime(self._simulation_view.getCurrentPath()) * SimulationViewProxy.FACTOR - # Since the timer cannot process time less than 1 ms, we put a lower limit here - return int(max(1, simulationTimeOfpath)) + @pyqtSlot(float, result=bool) + def advanceTime(self, duration: float) -> bool: + return self._simulation_view.advanceTime(duration) @pyqtProperty(int, notify=currentPathChanged) def minimumPath(self): @@ -91,8 +82,8 @@ class SimulationViewProxy(QObject): def setMinimumLayer(self, layer_num): self._simulation_view.setMinimumLayer(layer_num) - @pyqtSlot(int) - def setCurrentPath(self, path_num): + @pyqtSlot(float) + def setCurrentPath(self, path_num: float): self._simulation_view.setPath(path_num) @pyqtSlot(int) @@ -228,4 +219,3 @@ class SimulationViewProxy(QObject): self._simulation_view.activityChanged.disconnect(self._onActivityChanged) self._simulation_view.globalStackChanged.disconnect(self._onGlobalStackChanged) self._simulation_view.preferencesChanged.disconnect(self._onPreferencesChanged) - diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index aed38a3949..9a11bb886c 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -331,7 +331,7 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): return False [printer, *_] = self._printers - return printer.pinterType in ("ultimaker_methodx", "ultimaker_methodxl") + return printer.name in ("ultimaker_methodx", "ultimaker_methodxl") @pyqtProperty(bool, notify=_cloudClusterPrintersChanged) def supportsPrintJobActions(self) -> bool: diff --git a/requirements.txt b/requirements.txt index dfa974ef7d..76339c884f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,41 +1,47 @@ ### Direct requirements for Uranium and libCharon ### -PyQt6-sip==13.4.1 \ - --hash=sha256:0df998f2b6ceeacfd10de773441572e215be0c9cae566cc7dd36e231bf714a12 \ - --hash=sha256:224575e84805c4317bacd5d1b8e93e0ad5c48685dadbbe1e902d4ebe16f22828 \ - --hash=sha256:36ae29cdc223cacc1257d0f5075cf81474550c6d26b728f922487a2aa935f130 \ - --hash=sha256:3a674c591d4274d4ea8127205290e927a7dab0eb87a0038d4f4ea1d430782649 \ - --hash=sha256:3ef9392e4ae29d393b79237d85840cdc6b8831f36eed5d56c7d9b329b380cc8d \ - --hash=sha256:43935873d60f57719632840d517afee04ef8f30e92cfe0dadc7e6326691920fc \ - --hash=sha256:5731f22618435654352ef07684549a17be82b75254227fc80b4b5b0b59fc6656 \ - --hash=sha256:5bc4beb6fb1de4c9ba8beee7b1a4a813fa888c3b095206dafcd25d7e6e4ed2a7 \ - --hash=sha256:5c36ab984402e96792eebf4b031abfaa589aa20af3190a79c54502c16964d97e \ - --hash=sha256:a2a0461992c6657f343308b150c4d6b57e9e7a0e5c2f79538434e7fb869ea827 \ - --hash=sha256:a81490ee84d7a41a126b116081bd97d758f41bf706aee0a8cec24d6e4c660184 \ - --hash=sha256:e00e287ea05bbc293fc6e2198301962af9b7b622bd2daf4288f925a88ae35dc9 \ - --hash=sha256:e670a7b2fb7e32204ce67d274017bfff3e21139d217d60cebbfcb75b019c91ee \ - --hash=sha256:ee06f255787a0b4957f357f93b78d2a11ca3761916833e3afa83f1381d4d1a46 \ - --hash=sha256:fbee0d554e0e98f56dbf6d94b00a28cc32425938ad7ae98fd91f8822c5b24d45 \ - --hash=sha256:fcc6d78314783f4a193f02353f431b7ea4d357f47c3c7a7d0740e723f69c64dc -PyQt6==6.4.2 \ - --hash=sha256:18d1daf98d9236d55102cdadafd1056f5802f3c9288fcf7238569937b71a89f0 \ - --hash=sha256:25bd399b4a95dce65d5f937c1aa85d3c7e14a21745ae2a4ca14c0116cd104290 \ - --hash=sha256:740244f608fe15ee1d89695c43f31a14caeca41c4f02ac36c86dfba4a5d5813d \ - --hash=sha256:c128bc0f17833e324593e3db83e99470d451a197dd17ff0333927b946c935bd9 -PyQt6-Qt6==6.4.2 \ - --hash=sha256:9f07c3c100cb46cca4074965e7494d4df4f0fc016497d5303c1fe135822876e1 \ - --hash=sha256:a29b8c858babd523e80c8db5f8fd19792641588ec04eab49af18b7a4423eb99f \ - --hash=sha256:c0e91d0275d428496cacff717a9b719c52bfa52b21f124d638b79cc2217bc81e \ - --hash=sha256:d19c4e72615762cd6f0b043f23fa5f0b02656091427ce6de1efccd58e10e6a53 -PyQt6-NetworkAuth==6.4.0 \ - --hash=sha256:ab6178b3b2902ae9939a148555cfcee8c7803d6b0d5924cd1bd8f3407b8b9210 \ - --hash=sha256:c16ec80232d88024b60d04386a23cc93067e5644a65f47f26ffb13d84dcd4a6d \ - --hash=sha256:c302cd0d838c7229eda5e26e0b1b3d3ec4f8720f8d9379472bce5a89ff0735c2 \ - --hash=sha256:d948fc0cf43b64afbda2acb5bf2392f785a1e7a2950d79ea850c1a3f4ae12f1a -PyQt6-NetworkAuth-Qt6==6.4.2 \ - --hash=sha256:179094bcb4d4d056316c22d3d067cd94d4591da23f804461bfb025ccfa29b2b4 \ - --hash=sha256:1de6abbb5fa6585b97ae49d3f64b0dfad40bd56b1a31744d9775ff26247241c8 \ - --hash=sha256:79ec4b0fc9450bbedbff03541b93b10d1c7e761cd2cc16ce70d2b09dcdf8c720 \ - --hash=sha256:d96d557fe61edb9b68d189f270f0393d6579c8d308e6b0d41bc0699371d7cb4e +PyQt6-sip==13.6.0 \ + --hash=sha256:0dfd22cfedd87e96f9d51e0778ca2ba3dc0be83e424e9e0f98f6994d8d9c90f0 \ + --hash=sha256:13885361ca2cb2f5085d50359ba61b3fabd41b139fb58f37332acbe631ef2357 \ + --hash=sha256:24441032a29791e82beb7dfd76878339058def0e97fdb7c1cea517f3a0e6e96b \ + --hash=sha256:2486e1588071943d4f6657ba09096dc9fffd2322ad2c30041e78ea3f037b5778 \ + --hash=sha256:3075d8b325382750829e6cde6971c943352309d35768a4d4da0587459606d562 \ + --hash=sha256:33ea771fe777eb0d1a2c3ef35bcc3f7a286eb3ff09cd5b2fdd3d87d1f392d7e8 \ + --hash=sha256:39854dba35f8e5a4288da26ecb5f40b4c5ec1932efffb3f49d5ea435a7f37fb3 \ + --hash=sha256:3bf03e130fbfd75c9c06e687b86ba375410c7a9e835e4e03285889e61dd4b0c4 \ + --hash=sha256:43fb8551796030aae3d66d6e35e277494071ec6172cd182c9569ab7db268a2f5 \ + --hash=sha256:58f68a48400e0b3d1ccb18090090299bad26e3aed7ccb7057c65887b79b8aeea \ + --hash=sha256:5b9c6b6f9cfccb48cbb78a59603145a698fb4ffd176764d7083e5bf47631d8df \ + --hash=sha256:747f6ca44af81777a2c696bd501bc4815a53ec6fc94d4e25830e10bc1391f8ab \ + --hash=sha256:86a7b67c64436e32bffa9c28c9f21bf14a9faa54991520b12c3f6f435f24df7f \ + --hash=sha256:8c282062125eea5baf830c6998587d98c50be7c3a817a057fb95fef647184012 \ + --hash=sha256:8f9df9f7ccd8a9f0f1d36948c686f03ce1a1281543a3e636b7b7d5e086e1a436 \ + --hash=sha256:98bf954103b087162fa63b3a78f30b0b63da22fd6450b610ec1b851dbb798228 \ + --hash=sha256:9adf672f9114687533a74d5c2d4c03a9a929ad5ad9c3e88098a7da1a440ab916 \ + --hash=sha256:a6ce80bc24618d8a41be8ca51ad9f10e8bc4296dd90ab2809573df30a23ae0e5 \ + --hash=sha256:d6b5f699aaed0ac1fcd23e8fbca70d8a77965831b7c1ce474b81b1678817a49d \ + --hash=sha256:fa759b6339ff7e25f9afe2a6b651b775f0a36bcb3f5fa85e81a90d3b033c83f4 \ + --hash=sha256:fa7b10af7488efc5e53b41dd42c0f421bde6c2865a107af7ae259aff9d841da9 +PyQt6==6.6.0 \ + --hash=sha256:33655db05ac2de699320f035250c21434c77144a6a2943aca3f4c579dabc3f7b \ + --hash=sha256:3ef68830a9b32050c30f7962c56a5927802c9193b68eaf405faecb8ce9ae10a8 \ + --hash=sha256:d41512d66044c2df9c5f515a56a922170d68a37b3406ffddc8b4adc57181b576 \ + --hash=sha256:fc7185d65755f26d7a6842492ec5398c92544dc4eafbbcbef1b1922aca585c96 +PyQt6-Qt6==6.6.0 \ + --hash=sha256:1b079a33088d32ff47872cdb37fd15aa42101f0be46c3340244483849b781438 \ + --hash=sha256:8cb30d64a4d32465ea1686bc827cbe452225fb387c4873356b0fa7b9ae63534f \ + --hash=sha256:a151f34712cd645111e89cb30b02e5fb69c9dcc3603ab3c03a561e874bd7cbcf \ + --hash=sha256:e5483ae04bf107411c7469f1be9f9e2eb9840303e788b3ac524fe30af90d45f4 +PyQt6-NetworkAuth==6.6.0 \ + --hash=sha256:7b90b81792fe53105287c8cbb5e4b22bc44a482268ffb7d3e33f852807f86182 \ + --hash=sha256:c7e2335159aa795e2fe6fb069ccce6308672ab80f26c50fab57caf957371cbb5 \ + --hash=sha256:cdfc0bfaea16a9e09f075bdafefb996aa9fdec392052ba4fb3cbac233c1958fb \ + --hash=sha256:f60ff9a62f5129dc2a9d4c495fb47f9a03e4dfb666b50fb7d61f46e89bf7b6a2 +PyQt6-NetworkAuth-Qt6==6.6.0 \ + --hash=sha256:481d9093e1fb1ac6843d8beabcd359cc34b74b9a2cbb3e2b68d96bd3f178d4e0 \ + --hash=sha256:4cc48fd375730a0ba5fbed9d64abb2914f587377560a78a63aff893f9e276a45 \ + --hash=sha256:5006deabf55304d4a3e0b3c954f93e5835546b11e789d14653a2493d12d3a063 \ + --hash=sha256:bcd56bfc892fec961c51eba3c0bf32ba8317a762d9e254d3830569611ed569d6 + certifi==2023.5.7; \ --hash=sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716 cryptography==41.0.1 \ diff --git a/resources/definitions/fdmprinter.def.json b/resources/definitions/fdmprinter.def.json index b640fb5746..62143cf8f8 100644 --- a/resources/definitions/fdmprinter.def.json +++ b/resources/definitions/fdmprinter.def.json @@ -8305,6 +8305,88 @@ } } }, + "ppr": + { + "label": "Print Process Reporting", + "type": "category", + "icon": "DocumentFilled", + "description": "Reporting events that go out of set thresholds", + "enabled": false, + "children": + { + "ppr_enable": + { + "label": "Enable Print Process Reporting", + "description": "Enable print process reporting for setting threshold values for possible fault detection.", + "type": "bool", + "enabled": false, + "default_value": false, + "value": false, + "settable_per_mesh": false, + "settable_per_extruder": false + }, + "flow_warn_limit": + { + "label": "Flow Warning", + "description": "Limit on the flow warning for detection.", + "default_value": "15.0", + "enabled": "ppr_enable", + "unit": "%", + "type": "float", + "settable_per_extruder": true + }, + "flow_anomaly_limit": + { + "label": "Flow Limit", + "description": "Limit on flow anomaly for detection.", + "default_value": "25.0", + "enabled": "ppr_enable", + "unit": "%", + "type": "float", + "settable_per_extruder": true + }, + "print_temp_warn_limit": + { + "label": "Print temperature Warning", + "description": "Limit on Print temperature warning for detection.", + "unit": "\u00b0C", + "type": "float", + "default_value": "3.0", + "enabled": "ppr_enable", + "settable_per_extruder": true + }, + "print_temp_anomaly_limit": + { + "label": "Print temperature Limit", + "description": "Limit on Print Temperature anomaly for detection.", + "unit": "\u00b0C", + "type": "float", + "default_value": "7.0", + "enabled": "ppr_enable", + "settable_per_extruder": true + }, + "bv_temp_warn_limit": + { + "label": "Build Volume temperature Warning", + "description": "Limit on Build Volume Temperature warning for detection.", + "unit": "\u00b0C", + "type": "float", + "default_value": "7.5", + "enabled": "ppr_enable", + "settable_per_extruder": false + }, + "bv_temp_anomaly_limit": + { + "label": "Build Volume temperature Limit", + "description": "Limit on Build Volume temperature Anomaly for detection.", + "unit": "\u00b0C", + "type": "float", + "default_value": "10.0", + "enabled": "ppr_enable", + "settable_per_extruder": false + } + } + }, "command_line_settings": { "label": "Command Line Settings", diff --git a/resources/definitions/ultimaker_method_base.def.json b/resources/definitions/ultimaker_method_base.def.json index d45f54f8b9..4f03a8dbe1 100644 --- a/resources/definitions/ultimaker_method_base.def.json +++ b/resources/definitions/ultimaker_method_base.def.json @@ -354,11 +354,12 @@ "print_sequence": { "enabled": false }, "raft_base_line_spacing": { "value": "2*raft_base_line_width" }, "raft_base_line_width": { "value": 1.4 }, - "raft_base_speed": { "value": 5 }, + "raft_base_speed": { "value": 10 }, "raft_base_thickness": { "value": 0.8 }, "raft_interface_extruder_nr": { "value": "raft_surface_extruder_nr" }, "raft_interface_layers": { "value": 2 }, - "raft_interface_line_width": { "value": 1.2 }, + "raft_interface_line_width": { "value": 0.7 }, + "raft_interface_speed": { "value": 90 }, "raft_interface_thickness": { "value": 0.3 }, "raft_margin": { "value": 3 }, "raft_surface_extruder_nr": { "value": "int(anyExtruderWithMaterial('material_is_support_material')) if support_enable and extruderValue(support_extruder_nr,'material_is_support_material') else raft_base_extruder_nr" }, diff --git a/resources/qml/PrinterSelector/MachineSelector.qml b/resources/qml/PrinterSelector/MachineSelector.qml index b8b27049f6..1bad1d70bc 100644 --- a/resources/qml/PrinterSelector/MachineSelector.qml +++ b/resources/qml/PrinterSelector/MachineSelector.qml @@ -11,7 +11,7 @@ Cura.ExpandablePopup { id: machineSelector - property Cura.MachineManager machineManager + property var machineManager: Cura.MachineManager property bool isNetworkPrinter: machineManager.activeMachineHasNetworkConnection property bool isConnectedCloudPrinter: machineManager.activeMachineHasCloudConnection property bool isCloudRegistered: machineManager.activeMachineHasCloudRegistration @@ -107,6 +107,7 @@ Cura.ExpandablePopup { return UM.Theme.getIcon("Printer", "medium") } + else { return ""