Browse Source

Proof of concept for simulation
Co-authored-by: Casper Lamboo <c.lamboo@ultimaker.com>

CURA-7647

saumya.jain 1 year ago
parent
commit
cfec5e0cc1

+ 3 - 3
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.

+ 21 - 11
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:
+                                    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()
+                                else:
+                                    head_position = pos_a + 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

+ 92 - 32
plugins/SimulationView/SimulationView.py

@@ -40,7 +40,7 @@ from .SimulationViewProxy import SimulationViewProxy
 import numpy
 import os.path
 
-from typing import Optional, TYPE_CHECKING, List, cast
+from typing import Optional, TYPE_CHECKING, List, Tuple, cast
 
 if TYPE_CHECKING:
     from UM.Scene.SceneNode import SceneNode
@@ -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
@@ -99,13 +98,13 @@ class SimulationView(CuraView):
         self._min_flow_rate = sys.float_info.max
         self._max_flow_rate = sys.float_info.min
 
-        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 +126,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 +187,82 @@ 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:
+        self._current_time = time
+
+        left_i = 0
+        right_i = self._max_paths - 1
+
+        total_duration, cumulative_line_duration = self.cumulativeLineDuration()
+
+        # 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, cumulative_line_duration = self.cumulativeLineDuration()
+
+        # time ratio
+        time_increase = time_increase
+
+        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) -> Tuple[float, List[float]]:
+        # TODO: cache the total duration and cumulative line duration at each layer change event
+        cumulative_line_duration = []
+        total_duration = 0.0
+        for polyline in self.getLayerData().polygons:
+            for line_duration in list((polyline.lineLengths / polyline.lineFeedrates)[0]):
+                total_duration += line_duration
+                cumulative_line_duration.append(total_duration)
+        return total_duration, cumulative_line_duration
+
+    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 +350,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.
 
@@ -402,15 +473,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.
@@ -535,10 +597,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)

+ 6 - 41
plugins/SimulationView/SimulationViewMainComponent.qml

@@ -136,54 +136,19 @@ Item
     Timer
     {
         id: simulationTimer
-        interval: UM.SimulationView.simulationTime
+        interval: 1000 / 60
         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 accont 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;
         }
     }
 

+ 6 - 17
plugins/SimulationView/SimulationViewProxy.py

@@ -2,7 +2,6 @@
 # Cura is released under the terms of the LGPLv3 or higher.
 from typing import TYPE_CHECKING
 
-import numpy
 from PyQt6.QtCore import QObject, pyqtSignal, pyqtProperty
 from UM.FlameProfiler import pyqtSlot
 from UM.Application import Application
@@ -12,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
@@ -56,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):
@@ -92,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)
@@ -229,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)
-