Просмотр исходного кода

Merge pull request #15496 from Ultimaker/CURA-7951_lock_rotation

Add option to arrange in grid
Remco Burema 1 год назад
Родитель
Сommit
fa08848152

+ 15 - 4
cura/Arranging/ArrangeObjectsJob.py

@@ -8,17 +8,20 @@ from UM.Logger import Logger
 from UM.Message import Message
 from UM.Scene.SceneNode import SceneNode
 from UM.i18n import i18nCatalog
-from cura.Arranging.Nest2DArrange import arrange
+from cura.Arranging.GridArrange import GridArrange
+from cura.Arranging.Nest2DArrange import Nest2DArrange
 
 i18n_catalog = i18nCatalog("cura")
 
 
 class ArrangeObjectsJob(Job):
-    def __init__(self, nodes: List[SceneNode], fixed_nodes: List[SceneNode], min_offset = 8) -> None:
+    def __init__(self, nodes: List[SceneNode], fixed_nodes: List[SceneNode], min_offset = 8,
+                *, grid_arrange: bool = False) -> None:
         super().__init__()
         self._nodes = nodes
         self._fixed_nodes = fixed_nodes
         self._min_offset = min_offset
+        self._grid_arrange = grid_arrange
 
     def run(self):
         found_solution_for_all = False
@@ -29,10 +32,18 @@ class ArrangeObjectsJob(Job):
                                  title = i18n_catalog.i18nc("@info:title", "Finding Location"))
         status_message.show()
 
+        if self._grid_arrange:
+            arranger = GridArrange(self._nodes, Application.getInstance().getBuildVolume(), self._fixed_nodes)
+        else:
+            arranger = Nest2DArrange(self._nodes, Application.getInstance().getBuildVolume(), self._fixed_nodes,
+                                     factor=1000)
+
+        found_solution_for_all = False
         try:
-            found_solution_for_all = arrange(self._nodes, Application.getInstance().getBuildVolume(), self._fixed_nodes)
+            found_solution_for_all = arranger.arrange()
         except:  # If the thread crashes, the message should still close
-            Logger.logException("e", "Unable to arrange the objects on the buildplate. The arrange algorithm has crashed.")
+            Logger.logException("e",
+                                "Unable to arrange the objects on the buildplate. The arrange algorithm has crashed.")
 
         status_message.hide()
 

+ 28 - 0
cura/Arranging/Arranger.py

@@ -0,0 +1,28 @@
+from typing import List, TYPE_CHECKING, Optional, Tuple, Set
+
+if TYPE_CHECKING:
+    from UM.Operations.GroupedOperation import GroupedOperation
+
+
+class Arranger:
+    def createGroupOperationForArrange(self, *, add_new_nodes_in_scene: bool = False) -> Tuple["GroupedOperation", int]:
+        """
+        Find placement for a set of scene nodes, but don't actually move them just yet.
+        :param add_new_nodes_in_scene: Whether to create new scene nodes before applying the transformations and rotations
+        :return: tuple (found_solution_for_all, node_items)
+            WHERE
+            found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
+            node_items: A list of the nodes return by libnest2d, which contain the new positions on the buildplate
+        """
+        raise NotImplementedError
+
+    def arrange(self, *, add_new_nodes_in_scene: bool = False) -> bool:
+        """
+        Find placement for a set of scene nodes, and move them by using a single grouped operation.
+        :param add_new_nodes_in_scene: Whether to create new scene nodes before applying the transformations and rotations
+        :return: found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
+        """
+        grouped_operation, not_fit_count = self.createGroupOperationForArrange(
+            add_new_nodes_in_scene=add_new_nodes_in_scene)
+        grouped_operation.push()
+        return not_fit_count == 0

+ 331 - 0
cura/Arranging/GridArrange.py

@@ -0,0 +1,331 @@
+import math
+from typing import List, TYPE_CHECKING, Tuple, Set
+
+if TYPE_CHECKING:
+    from UM.Scene.SceneNode import SceneNode
+    from cura.BuildVolume import BuildVolume
+
+from UM.Application import Application
+from UM.Math.Vector import Vector
+from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
+from UM.Operations.GroupedOperation import GroupedOperation
+from UM.Operations.TranslateOperation import TranslateOperation
+from cura.Arranging.Arranger import Arranger
+
+
+class GridArrange(Arranger):
+    def __init__(self, nodes_to_arrange: List["SceneNode"], build_volume: "BuildVolume", fixed_nodes: List["SceneNode"] = None):
+        if fixed_nodes is None:
+            fixed_nodes = []
+        self._nodes_to_arrange = nodes_to_arrange
+        self._build_volume = build_volume
+        self._build_volume_bounding_box = build_volume.getBoundingBox()
+        self._fixed_nodes = fixed_nodes
+
+        self._margin_x: float = 1
+        self._margin_y: float = 1
+
+        self._grid_width = 0
+        self._grid_height = 0
+        for node in self._nodes_to_arrange:
+            bounding_box = node.getBoundingBox()
+            self._grid_width = max(self._grid_width, bounding_box.width)
+            self._grid_height = max(self._grid_height, bounding_box.depth)
+        self._grid_width += self._margin_x
+        self._grid_height += self._margin_y
+
+        # Round up the grid size to the nearest cm
+        grid_precision = 10  # 1cm
+        self._grid_width = math.ceil(self._grid_width / grid_precision) * grid_precision
+        self._grid_height = math.ceil(self._grid_height / grid_precision) * grid_precision
+
+        self._offset_x = 0
+        self._offset_y = 0
+        self._findOptimalGridOffset()
+
+        coord_initial_leftover_x = self._build_volume_bounding_box.right + 2 * self._grid_width
+        coord_initial_leftover_y = (self._build_volume_bounding_box.back + self._build_volume_bounding_box.front) * 0.5
+        self._initial_leftover_grid_x, self._initial_leftover_grid_y = self._coordSpaceToGridSpace(
+            coord_initial_leftover_x, coord_initial_leftover_y)
+        self._initial_leftover_grid_x = math.floor(self._initial_leftover_grid_x)
+        self._initial_leftover_grid_y = math.floor(self._initial_leftover_grid_y)
+
+        # Find grid indexes that intersect with fixed objects
+        self._fixed_nodes_grid_ids = set()
+        for node in self._fixed_nodes:
+            self._fixed_nodes_grid_ids = self._fixed_nodes_grid_ids.union(
+                self._intersectingGridIdxInclusive(node.getBoundingBox()))
+
+        #grid indexes that are in disallowed area
+        for polygon in self._build_volume.getDisallowedAreas():
+            self._fixed_nodes_grid_ids = self._fixed_nodes_grid_ids.union(
+            self._getIntersectingGridIdForPolygon(polygon))
+
+        self._build_plate_grid_ids = self._intersectingGridIdxExclusive(self._build_volume_bounding_box)
+
+        # Filter out the corner grid squares if the build plate shape is elliptic
+        if self._build_volume.getShape() == "elliptic":
+            self._build_plate_grid_ids = set(
+                filter(lambda grid_id: self._checkGridUnderDiscSpace(grid_id[0], grid_id[1]),
+                       self._build_plate_grid_ids))
+
+        self._allowed_grid_idx = self._build_plate_grid_ids.difference(self._fixed_nodes_grid_ids)
+
+    def createGroupOperationForArrange(self, *, add_new_nodes_in_scene: bool = False) -> Tuple[GroupedOperation, int]:
+        # Find the sequence in which items are placed
+        coord_build_plate_center_x = self._build_volume_bounding_box.width * 0.5 + self._build_volume_bounding_box.left
+        coord_build_plate_center_y = self._build_volume_bounding_box.depth * 0.5 + self._build_volume_bounding_box.back
+        grid_build_plate_center_x, grid_build_plate_center_y = self._coordSpaceToGridSpace(coord_build_plate_center_x,
+                                                                                           coord_build_plate_center_y)
+
+        sequence: List[Tuple[int, int]] = list(self._allowed_grid_idx)
+        sequence.sort(key=lambda grid_id: (grid_build_plate_center_x - grid_id[0]) ** 2 + (
+                    grid_build_plate_center_y - grid_id[1]) ** 2)
+        scene_root = Application.getInstance().getController().getScene().getRoot()
+        grouped_operation = GroupedOperation()
+
+        for grid_id, node in zip(sequence, self._nodes_to_arrange):
+            if add_new_nodes_in_scene:
+                grouped_operation.addOperation(AddSceneNodeOperation(node, scene_root))
+            grid_x, grid_y = grid_id
+            operation = self._moveNodeOnGrid(node, grid_x, grid_y)
+            grouped_operation.addOperation(operation)
+
+        leftover_nodes = self._nodes_to_arrange[len(sequence):]
+
+        left_over_grid_y = self._initial_leftover_grid_y
+        for node in leftover_nodes:
+            if add_new_nodes_in_scene:
+                grouped_operation.addOperation(AddSceneNodeOperation(node, scene_root))
+            # find the first next grid position that isn't occupied by a fixed node
+            while (self._initial_leftover_grid_x, left_over_grid_y) in self._fixed_nodes_grid_ids:
+                left_over_grid_y = left_over_grid_y - 1
+
+            operation = self._moveNodeOnGrid(node, self._initial_leftover_grid_x, left_over_grid_y)
+            grouped_operation.addOperation(operation)
+            left_over_grid_y = left_over_grid_y - 1
+
+        return grouped_operation, len(leftover_nodes)
+
+    def _findOptimalGridOffset(self):
+        if len(self._fixed_nodes) == 0:
+            self._offset_x = 0
+            self._offset_y = 0
+            return
+
+        if len(self._fixed_nodes) == 1:
+            center_grid_x = 0.5 * self._grid_width + self._build_volume_bounding_box.left
+            center_grid_y = 0.5 * self._grid_height + self._build_volume_bounding_box.back
+
+            bounding_box = self._fixed_nodes[0].getBoundingBox()
+            center_node_x = (bounding_box.left + bounding_box.right) * 0.5
+            center_node_y = (bounding_box.back + bounding_box.front) * 0.5
+
+            self._offset_x = center_node_x - center_grid_x
+            self._offset_y = center_node_y - center_grid_y
+
+            return
+
+        # If there are multiple fixed nodes, an optimal solution is not always possible
+        # We will try to find an offset that minimizes the number of grid intersections
+        # with fixed nodes. The algorithm below achieves this by utilizing a scanline
+        # algorithm. In this algorithm each axis is solved separately as offsetting
+        # is completely independent in each axis. The comments explaining the algorithm
+        # below are for the x-axis, but the same applies for the y-axis.
+        #
+        # Each node either occupies ceil((node.right - node.right) / grid_width) or
+        # ceil((node.right - node.right) / grid_width) + 1 grid squares. We will call
+        # these the node's "footprint".
+        #
+        #                      ┌────────────────┐
+        #   minimum foot-print │      NODE      │
+        #                      └────────────────┘
+        # │    grid 1   │    grid 2    │    grid 3    │    grid 4    |    grid 5    |
+        #                             ┌────────────────┐
+        #          maximum foot-print │      NODE      │
+        #                             └────────────────┘
+        #
+        # The algorithm will find the grid offset such that the number of nodes with
+        # a _minimal_ footprint is _maximized_.
+
+        # The scanline algorithm works as follows, we create events for both end points
+        # of each node's footprint. The event have two properties,
+        # - the coordinate: the amount the endpoint can move to the
+        #      left before it crosses a grid line
+        # - the change: either +1 or -1, indicating whether crossing the grid line
+        #      would result in a minimal footprint node becoming a maximal footprint
+        class Event:
+            def __init__(self, coord: float, change: float):
+                self.coord = coord
+                self.change = change
+
+        # create events for both the horizontal and vertical axis
+        events_horizontal: List[Event] = []
+        events_vertical: List[Event] = []
+
+        for node in self._fixed_nodes:
+            bounding_box = node.getBoundingBox()
+
+            left = bounding_box.left - self._build_volume_bounding_box.left
+            right = bounding_box.right - self._build_volume_bounding_box.left
+            back = bounding_box.back - self._build_volume_bounding_box.back
+            front = bounding_box.front - self._build_volume_bounding_box.back
+
+            value_left = math.ceil(left / self._grid_width) * self._grid_width - left
+            value_right = math.ceil(right / self._grid_width) * self._grid_width - right
+            value_back = math.ceil(back / self._grid_height) * self._grid_height - back
+            value_front = math.ceil(front / self._grid_height) * self._grid_height - front
+
+            # give nodes a weight according to their size. This
+            # weight is heuristically chosen to be proportional to
+            # the number of grid squares the node-boundary occupies
+            weight = bounding_box.width + bounding_box.depth
+
+            events_horizontal.append(Event(value_left, weight))
+            events_horizontal.append(Event(value_right, -weight))
+            events_vertical.append(Event(value_back, weight))
+            events_vertical.append(Event(value_front, -weight))
+
+        events_horizontal.sort(key=lambda event: event.coord)
+        events_vertical.sort(key=lambda event: event.coord)
+
+        def findOptimalShiftAxis(events: List[Event], interval: float) -> float:
+            # executing the actual scanline algorithm
+            # iteratively go through events (left to right) and keep track of the
+            # current footprint. The optimal location is the one with the minimal
+            # footprint. If there are multiple locations with the same minimal
+            # footprint, the optimal location is the one with the largest range
+            # between the left and right endpoint of the footprint.
+            prev_offset = events[-1].coord - interval
+            current_minimal_footprint_count = 0
+
+            best_minimal_footprint_count = float('inf')
+            best_offset_span = float('-inf')
+            best_offset = 0.0
+
+            for event in events:
+                offset_span = event.coord - prev_offset
+
+                if current_minimal_footprint_count < best_minimal_footprint_count or (
+                        current_minimal_footprint_count == best_minimal_footprint_count and offset_span > best_offset_span):
+                    best_minimal_footprint_count = current_minimal_footprint_count
+                    best_offset_span = offset_span
+                    best_offset = event.coord
+
+                current_minimal_footprint_count += event.change
+                prev_offset = event.coord
+
+            return best_offset - best_offset_span * 0.5
+
+        center_grid_x = 0.5 * self._grid_width
+        center_grid_y = 0.5 * self._grid_height
+
+        optimal_center_x = self._grid_width - findOptimalShiftAxis(events_horizontal, self._grid_width)
+        optimal_center_y = self._grid_height - findOptimalShiftAxis(events_vertical, self._grid_height)
+
+        self._offset_x = optimal_center_x - center_grid_x
+        self._offset_y = optimal_center_y - center_grid_y
+
+    def _moveNodeOnGrid(self, node: "SceneNode", grid_x: int, grid_y: int) -> "Operation.Operation":
+        coord_grid_x, coord_grid_y = self._gridSpaceToCoordSpace(grid_x, grid_y)
+        center_grid_x = coord_grid_x + (0.5 * self._grid_width)
+        center_grid_y = coord_grid_y + (0.5 * self._grid_height)
+
+        bounding_box = node.getBoundingBox()
+        center_node_x = (bounding_box.left + bounding_box.right) * 0.5
+        center_node_y = (bounding_box.back + bounding_box.front) * 0.5
+
+        delta_x = center_grid_x - center_node_x
+        delta_y = center_grid_y - center_node_y
+
+        return TranslateOperation(node, Vector(delta_x, 0, delta_y))
+
+    def _getGridCornerPoints(self, bounding_box: "BoundingVolume") -> Tuple[float, float, float, float]:
+        coord_x1 = bounding_box.left
+        coord_x2 = bounding_box.right
+        coord_y1 = bounding_box.back
+        coord_y2 = bounding_box.front
+        grid_x1, grid_y1 = self._coordSpaceToGridSpace(coord_x1, coord_y1)
+        grid_x2, grid_y2 = self._coordSpaceToGridSpace(coord_x2, coord_y2)
+        return grid_x1, grid_y1, grid_x2, grid_y2
+
+    def _getIntersectingGridIdForPolygon(self, polygon)-> Set[Tuple[int, int]]:
+        #       (x0, y0)
+        #       |
+        #       v
+        #       ┌─────────────┐
+        #       │             │
+        #       │             │
+        #       └─────────────┘  < (x1, y1)
+        x0 = float('inf')
+        y0 = float('inf')
+        x1 = float('-inf')
+        y1 = float('-inf')
+        grid_idx = set()
+        for [x, y] in polygon.getPoints():
+            x0 = min(x0, x)
+            y0 = min(y0, y)
+            x1 = max(x1, x)
+            y1 = max(y1, y)
+        grid_x1, grid_y1 = self._coordSpaceToGridSpace(x0, y0)
+        grid_x2, grid_y2 = self._coordSpaceToGridSpace(x1, y1)
+
+        for grid_x in range(math.floor(grid_x1), math.ceil(grid_x2)):
+            for grid_y in range(math.floor(grid_y1), math.ceil(grid_y2)):
+                grid_idx.add((grid_x, grid_y))
+        return grid_idx
+
+    def _intersectingGridIdxInclusive(self, bounding_box: "BoundingVolume") -> Set[Tuple[int, int]]:
+        grid_x1, grid_y1, grid_x2, grid_y2 = self._getGridCornerPoints(bounding_box)
+        grid_idx = set()
+        for grid_x in range(math.floor(grid_x1), math.ceil(grid_x2)):
+            for grid_y in range(math.floor(grid_y1), math.ceil(grid_y2)):
+                grid_idx.add((grid_x, grid_y))
+        return grid_idx
+
+    def _intersectingGridIdxExclusive(self, bounding_box: "BoundingVolume") -> Set[Tuple[int, int]]:
+        grid_x1, grid_y1, grid_x2, grid_y2 = self._getGridCornerPoints(bounding_box)
+        grid_idx = set()
+        for grid_x in range(math.ceil(grid_x1), math.floor(grid_x2)):
+            for grid_y in range(math.ceil(grid_y1), math.floor(grid_y2)):
+                grid_idx.add((grid_x, grid_y))
+        return grid_idx
+
+    def _gridSpaceToCoordSpace(self, x: float, y: float) -> Tuple[float, float]:
+        grid_x = x * self._grid_width + self._build_volume_bounding_box.left + self._offset_x
+        grid_y = y * self._grid_height + self._build_volume_bounding_box.back + self._offset_y
+        return grid_x, grid_y
+
+    def _coordSpaceToGridSpace(self, grid_x: float, grid_y: float) -> Tuple[float, float]:
+        coord_x = (grid_x - self._build_volume_bounding_box.left - self._offset_x) / self._grid_width
+        coord_y = (grid_y - self._build_volume_bounding_box.back - self._offset_y) / self._grid_height
+        return coord_x, coord_y
+
+    def _checkGridUnderDiscSpace(self, grid_x: int, grid_y: int) -> bool:
+        left, back = self._gridSpaceToCoordSpace(grid_x, grid_y)
+        right, front = self._gridSpaceToCoordSpace(grid_x + 1, grid_y + 1)
+        corners = [(left, back), (right, back), (right, front), (left, front)]
+        return all([self._checkPointUnderDiscSpace(x, y) for x, y in corners])
+
+    def _checkPointUnderDiscSpace(self, x: float, y: float) -> bool:
+        disc_x, disc_y = self._coordSpaceToDiscSpace(x, y)
+        distance_to_center_squared = disc_x ** 2 + disc_y ** 2
+        return distance_to_center_squared <= 1.0
+
+    def _coordSpaceToDiscSpace(self, x: float, y: float) -> Tuple[float, float]:
+        # Transform coordinate system to
+        #
+        #       coord_build_plate_left = -1
+        #       |               coord_build_plate_right = 1
+        #       v     (0,1)     v
+        #       ┌───────┬───────┐  < coord_build_plate_back = -1
+        #       │       │       │
+        #       │       │(0,0)  │
+        # (-1,0)├───────o───────┤(1,0)
+        #       │       │       │
+        #       │       │       │
+        #       └───────┴───────┘  < coord_build_plate_front = +1
+        #             (0,-1)
+        disc_x = ((x - self._build_volume_bounding_box.left) / self._build_volume_bounding_box.width) * 2.0 - 1.0
+        disc_y = ((y - self._build_volume_bounding_box.back) / self._build_volume_bounding_box.depth) * 2.0 - 1.0
+        return disc_x, disc_y

+ 125 - 137
cura/Arranging/Nest2DArrange.py

@@ -15,149 +15,137 @@ from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
 from UM.Operations.GroupedOperation import GroupedOperation
 from UM.Operations.RotateOperation import RotateOperation
 from UM.Operations.TranslateOperation import TranslateOperation
-
+from cura.Arranging.Arranger import Arranger
 
 if TYPE_CHECKING:
     from UM.Scene.SceneNode import SceneNode
     from cura.BuildVolume import BuildVolume
 
 
-def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildVolume", fixed_nodes: Optional[List["SceneNode"]] = None, factor = 10000) -> Tuple[bool, List[Item]]:
-    """
-    Find placement for a set of scene nodes, but don't actually move them just yet.
-    :param nodes_to_arrange: The list of nodes that need to be moved.
-    :param build_volume: The build volume that we want to place the nodes in. It gets size & disallowed areas from this.
-    :param fixed_nodes: List of nods that should not be moved, but should be used when deciding where the others nodes
-                        are placed.
-    :param factor: The library that we use is int based. This factor defines how accurate we want it to be.
-
-    :return: tuple (found_solution_for_all, node_items)
-        WHERE
-        found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
-        node_items: A list of the nodes return by libnest2d, which contain the new positions on the buildplate
-    """
-    spacing = int(1.5 * factor)  # 1.5mm spacing.
-
-    machine_width = build_volume.getWidth()
-    machine_depth = build_volume.getDepth()
-    build_plate_bounding_box = Box(int(machine_width * factor), int(machine_depth * factor))
-
-    if fixed_nodes is None:
-        fixed_nodes = []
-
-    # Add all the items we want to arrange
-    node_items = []
-    for node in nodes_to_arrange:
-        hull_polygon = node.callDecoration("getConvexHull")
-        if not hull_polygon or hull_polygon.getPoints is None:
-            Logger.log("w", "Object {} cannot be arranged because it has no convex hull.".format(node.getName()))
-            continue
-        converted_points = []
-        for point in hull_polygon.getPoints():
-            converted_points.append(Point(int(point[0] * factor), int(point[1] * factor)))
-        item = Item(converted_points)
-        node_items.append(item)
-
-    # Use a tiny margin for the build_plate_polygon (the nesting doesn't like overlapping disallowed areas)
-    half_machine_width = 0.5 * machine_width - 1
-    half_machine_depth = 0.5 * machine_depth - 1
-    build_plate_polygon = Polygon(numpy.array([
-        [half_machine_width, -half_machine_depth],
-        [-half_machine_width, -half_machine_depth],
-        [-half_machine_width, half_machine_depth],
-        [half_machine_width, half_machine_depth]
-    ], numpy.float32))
-
-    disallowed_areas = build_volume.getDisallowedAreas()
-    num_disallowed_areas_added = 0
-    for area in disallowed_areas:
-        converted_points = []
-
-        # Clip the disallowed areas so that they don't overlap the bounding box (The arranger chokes otherwise)
-        clipped_area = area.intersectionConvexHulls(build_plate_polygon)
-
-        if clipped_area.getPoints() is not None and len(clipped_area.getPoints()) > 2:  # numpy array has to be explicitly checked against None
-            for point in clipped_area.getPoints():
-                converted_points.append(Point(int(point[0] * factor), int(point[1] * factor)))
-
-            disallowed_area = Item(converted_points)
-            disallowed_area.markAsDisallowedAreaInBin(0)
-            node_items.append(disallowed_area)
-            num_disallowed_areas_added += 1
-
-    for node in fixed_nodes:
-        converted_points = []
-        hull_polygon = node.callDecoration("getConvexHull")
-
-        if hull_polygon is not None and hull_polygon.getPoints() is not None and len(hull_polygon.getPoints()) > 2:  # numpy array has to be explicitly checked against None
+class Nest2DArrange(Arranger):
+    def __init__(self,
+                 nodes_to_arrange: List["SceneNode"],
+                 build_volume: "BuildVolume",
+                 fixed_nodes: Optional[List["SceneNode"]] = None,
+                 *,
+                 factor: int = 10000,
+                 lock_rotation: bool = False):
+        """
+        :param nodes_to_arrange: The list of nodes that need to be moved.
+        :param build_volume: The build volume that we want to place the nodes in. It gets size & disallowed areas from this.
+        :param fixed_nodes: List of nods that should not be moved, but should be used when deciding where the others nodes
+                            are placed.
+        :param factor: The library that we use is int based. This factor defines how accuracte we want it to be.
+        :param lock_rotation: If set to true the orientation of the object will remain the same
+        """
+        super().__init__()
+        self._nodes_to_arrange = nodes_to_arrange
+        self._build_volume = build_volume
+        self._fixed_nodes = fixed_nodes
+        self._factor = factor
+        self._lock_rotation = lock_rotation
+
+    def findNodePlacement(self) -> Tuple[bool, List[Item]]:
+        spacing = int(1.5 * self._factor)  # 1.5mm spacing.
+
+        machine_width = self._build_volume.getWidth()
+        machine_depth = self._build_volume.getDepth()
+        build_plate_bounding_box = Box(int(machine_width * self._factor), int(machine_depth * self._factor))
+
+        if self._fixed_nodes is None:
+            self._fixed_nodes = []
+
+        # Add all the items we want to arrange
+        node_items = []
+        for node in self._nodes_to_arrange:
+            hull_polygon = node.callDecoration("getConvexHull")
+            if not hull_polygon or hull_polygon.getPoints is None:
+                Logger.log("w", "Object {} cannot be arranged because it has no convex hull.".format(node.getName()))
+                continue
+            converted_points = []
             for point in hull_polygon.getPoints():
-                converted_points.append(Point(int(point[0] * factor), int(point[1] * factor)))
+                converted_points.append(Point(int(point[0] * self._factor), int(point[1] * self._factor)))
             item = Item(converted_points)
-            item.markAsFixedInBin(0)
             node_items.append(item)
-            num_disallowed_areas_added += 1
-
-    config = NfpConfig()
-    config.accuracy = 1.0
-    config.alignment = NfpConfig.Alignment.DONT_ALIGN
-
-    num_bins = nest(node_items, build_plate_bounding_box, spacing, config)
-
-    # Strip the fixed items (previously placed) and the disallowed areas from the results again.
-    node_items = list(filter(lambda item: not item.isFixed(), node_items))
-
-    found_solution_for_all = num_bins == 1
-
-    return found_solution_for_all, node_items
-
-
-def createGroupOperationForArrange(nodes_to_arrange: List["SceneNode"],
-                                   build_volume: "BuildVolume",
-                                   fixed_nodes: Optional[List["SceneNode"]] = None,
-                                   factor = 10000,
-                                   add_new_nodes_in_scene: bool = False)  -> Tuple[GroupedOperation, int]:
-    scene_root = Application.getInstance().getController().getScene().getRoot()
-    found_solution_for_all, node_items = findNodePlacement(nodes_to_arrange, build_volume, fixed_nodes, factor)
-
-    not_fit_count = 0
-    grouped_operation = GroupedOperation()
-    for node, node_item in zip(nodes_to_arrange, node_items):
-        if add_new_nodes_in_scene:
-            grouped_operation.addOperation(AddSceneNodeOperation(node, scene_root))
-
-        if node_item.binId() == 0:
-            # We found a spot for it
-            rotation_matrix = Matrix()
-            rotation_matrix.setByRotationAxis(node_item.rotation(), Vector(0, -1, 0))
-            grouped_operation.addOperation(RotateOperation(node, Quaternion.fromMatrix(rotation_matrix)))
-            grouped_operation.addOperation(TranslateOperation(node, Vector(node_item.translation().x() / factor, 0,
-                                                                           node_item.translation().y() / factor)))
-        else:
-            # We didn't find a spot
-            grouped_operation.addOperation(
-                TranslateOperation(node, Vector(200, node.getWorldPosition().y, -not_fit_count * 20), set_position = True))
-            not_fit_count += 1
-
-    return grouped_operation, not_fit_count
-
-
-def arrange(nodes_to_arrange: List["SceneNode"],
-            build_volume: "BuildVolume",
-            fixed_nodes: Optional[List["SceneNode"]] = None,
-            factor = 10000,
-            add_new_nodes_in_scene: bool = False) -> bool:
-    """
-    Find placement for a set of scene nodes, and move them by using a single grouped operation.
-    :param nodes_to_arrange: The list of nodes that need to be moved.
-    :param build_volume: The build volume that we want to place the nodes in. It gets size & disallowed areas from this.
-    :param fixed_nodes: List of nods that should not be moved, but should be used when deciding where the others nodes
-                        are placed.
-    :param factor: The library that we use is int based. This factor defines how accuracte we want it to be.
-    :param add_new_nodes_in_scene: Whether to create new scene nodes before applying the transformations and rotations
-
-    :return: found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
-    """
-
-    grouped_operation, not_fit_count = createGroupOperationForArrange(nodes_to_arrange, build_volume, fixed_nodes, factor, add_new_nodes_in_scene)
-    grouped_operation.push()
-    return not_fit_count == 0
+
+        # Use a tiny margin for the build_plate_polygon (the nesting doesn't like overlapping disallowed areas)
+        half_machine_width = 0.5 * machine_width - 1
+        half_machine_depth = 0.5 * machine_depth - 1
+        build_plate_polygon = Polygon(numpy.array([
+            [half_machine_width, -half_machine_depth],
+            [-half_machine_width, -half_machine_depth],
+            [-half_machine_width, half_machine_depth],
+            [half_machine_width, half_machine_depth]
+        ], numpy.float32))
+
+        disallowed_areas = self._build_volume.getDisallowedAreas()
+        num_disallowed_areas_added = 0
+        for area in disallowed_areas:
+            converted_points = []
+
+            # Clip the disallowed areas so that they don't overlap the bounding box (The arranger chokes otherwise)
+            clipped_area = area.intersectionConvexHulls(build_plate_polygon)
+
+            if clipped_area.getPoints() is not None and len(
+                    clipped_area.getPoints()) > 2:  # numpy array has to be explicitly checked against None
+                for point in clipped_area.getPoints():
+                    converted_points.append(Point(int(point[0] * self._factor), int(point[1] * self._factor)))
+
+                disallowed_area = Item(converted_points)
+                disallowed_area.markAsDisallowedAreaInBin(0)
+                node_items.append(disallowed_area)
+                num_disallowed_areas_added += 1
+
+        for node in self._fixed_nodes:
+            converted_points = []
+            hull_polygon = node.callDecoration("getConvexHull")
+
+            if hull_polygon is not None and hull_polygon.getPoints() is not None and len(
+                    hull_polygon.getPoints()) > 2:  # numpy array has to be explicitly checked against None
+                for point in hull_polygon.getPoints():
+                    converted_points.append(Point(int(point[0] * self._factor), int(point[1] * self._factor)))
+                item = Item(converted_points)
+                item.markAsFixedInBin(0)
+                node_items.append(item)
+                num_disallowed_areas_added += 1
+
+        config = NfpConfig()
+        config.accuracy = 1.0
+        config.alignment = NfpConfig.Alignment.DONT_ALIGN
+        if self._lock_rotation:
+            config.rotations = [0.0]
+
+        num_bins = nest(node_items, build_plate_bounding_box, spacing, config)
+
+        # Strip the fixed items (previously placed) and the disallowed areas from the results again.
+        node_items = list(filter(lambda item: not item.isFixed(), node_items))
+
+        found_solution_for_all = num_bins == 1
+
+        return found_solution_for_all, node_items
+
+    def createGroupOperationForArrange(self, *, add_new_nodes_in_scene: bool = False) -> Tuple[GroupedOperation, int]:
+        scene_root = Application.getInstance().getController().getScene().getRoot()
+        found_solution_for_all, node_items = self.findNodePlacement()
+
+        not_fit_count = 0
+        grouped_operation = GroupedOperation()
+        for node, node_item in zip(self._nodes_to_arrange, node_items):
+            if add_new_nodes_in_scene:
+                grouped_operation.addOperation(AddSceneNodeOperation(node, scene_root))
+
+            if node_item.binId() == 0:
+                # We found a spot for it
+                rotation_matrix = Matrix()
+                rotation_matrix.setByRotationAxis(node_item.rotation(), Vector(0, -1, 0))
+                grouped_operation.addOperation(RotateOperation(node, Quaternion.fromMatrix(rotation_matrix)))
+                grouped_operation.addOperation(
+                    TranslateOperation(node, Vector(node_item.translation().x() / self._factor, 0,
+                                                    node_item.translation().y() / self._factor)))
+            else:
+                # We didn't find a spot
+                grouped_operation.addOperation(
+                    TranslateOperation(node, Vector(200, node.getWorldPosition().y, -not_fit_count * 20), set_position = True))
+                not_fit_count += 1
+
+        return grouped_operation, not_fit_count

+ 3 - 0
cura/BuildVolume.py

@@ -203,6 +203,9 @@ class BuildVolume(SceneNode):
         if shape:
             self._shape = shape
 
+    def getShape(self) -> str:
+        return self._shape
+
     def getDiagonalSize(self) -> float:
         """Get the length of the 3D diagonal through the build volume.
 

+ 18 - 6
cura/CuraActions.py

@@ -22,7 +22,10 @@ from cura.Operations.SetParentOperation import SetParentOperation
 from cura.MultiplyObjectsJob import MultiplyObjectsJob
 from cura.Settings.SetObjectExtruderOperation import SetObjectExtruderOperation
 from cura.Settings.ExtruderManager import ExtruderManager
-from cura.Arranging.Nest2DArrange import createGroupOperationForArrange
+
+from cura.Arranging.GridArrange import GridArrange
+from cura.Arranging.Nest2DArrange import Nest2DArrange
+
 
 from cura.Operations.SetBuildPlateNumberOperation import SetBuildPlateNumberOperation
 
@@ -82,16 +85,25 @@ class CuraActions(QObject):
             center_operation = TranslateOperation(current_node, Vector(0, center_y, 0), set_position = True)
             operation.addOperation(center_operation)
         operation.push()
-
     @pyqtSlot(int)
     def multiplySelection(self, count: int) -> None:
         """Multiply all objects in the selection
+        :param count: The number of times to multiply the selection.
+        """
+        min_offset = cura.CuraApplication.CuraApplication.getInstance().getBuildVolume().getEdgeDisallowedSize() + 2  # Allow for some rounding errors
+        job = MultiplyObjectsJob(Selection.getAllSelectedObjects(), count, min_offset = max(min_offset, 8))
+        job.start()
+
+    @pyqtSlot(int)
+    def multiplySelectionToGrid(self, count: int) -> None:
+        """Multiply all objects in the selection
 
         :param count: The number of times to multiply the selection.
         """
 
         min_offset = cura.CuraApplication.CuraApplication.getInstance().getBuildVolume().getEdgeDisallowedSize() + 2  # Allow for some rounding errors
-        job = MultiplyObjectsJob(Selection.getAllSelectedObjects(), count, min_offset = max(min_offset, 8))
+        job = MultiplyObjectsJob(Selection.getAllSelectedObjects(), count, min_offset=max(min_offset, 8),
+                                 grid_arrange=True)
         job.start()
 
     @pyqtSlot()
@@ -229,9 +241,9 @@ class CuraActions(QObject):
             if node.callDecoration("isSliceable"):
                 fixed_nodes.append(node)
         # Add the new nodes to the scene, and arrange them
-        group_operation, not_fit_count = createGroupOperationForArrange(nodes, application.getBuildVolume(),
-                                                                        fixed_nodes, factor=10000,
-                                                                        add_new_nodes_in_scene=True)
+
+        arranger = GridArrange(nodes, application.getBuildVolume(), fixed_nodes)
+        group_operation, not_fit_count = arranger.createGroupOperationForArrange(add_new_nodes_in_scene = True)
         group_operation.push()
 
         # deselect currently selected nodes, and select the new nodes

+ 14 - 6
cura/CuraApplication.py

@@ -54,7 +54,6 @@ from cura import ApplicationMetadata
 from cura.API import CuraAPI
 from cura.API.Account import Account
 from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob
-from cura.Arranging.Nest2DArrange import arrange
 from cura.Machines.MachineErrorChecker import MachineErrorChecker
 from cura.Machines.Models.BuildPlateModel import BuildPlateModel
 from cura.Machines.Models.CustomQualityProfilesDropDownMenuModel import CustomQualityProfilesDropDownMenuModel
@@ -115,6 +114,7 @@ from . import CameraAnimation
 from . import CuraActions
 from . import PlatformPhysics
 from . import PrintJobPreviewImageProvider
+from .Arranging.Nest2DArrange import Nest2DArrange
 from .AutoSave import AutoSave
 from .Machines.Models.CompatibleMachineModel import CompatibleMachineModel
 from .Machines.Models.MachineListModel import MachineListModel
@@ -1444,6 +1444,13 @@ class CuraApplication(QtApplication):
     # Single build plate
     @pyqtSlot()
     def arrangeAll(self) -> None:
+        self._arrangeAll(grid_arrangement = False)
+
+    @pyqtSlot()
+    def arrangeAllInGrid(self) -> None:
+        self._arrangeAll(grid_arrangement = True)
+
+    def _arrangeAll(self, *, grid_arrangement: bool) -> None:
         nodes_to_arrange = []
         active_build_plate = self.getMultiBuildPlateModel().activeBuildPlate
         locked_nodes = []
@@ -1473,17 +1480,17 @@ class CuraApplication(QtApplication):
                         locked_nodes.append(node)
                     else:
                         nodes_to_arrange.append(node)
-        self.arrange(nodes_to_arrange, locked_nodes)
+        self.arrange(nodes_to_arrange, locked_nodes, grid_arrangement = grid_arrangement)
 
-    def arrange(self, nodes: List[SceneNode], fixed_nodes: List[SceneNode]) -> None:
+    def arrange(self, nodes: List[SceneNode], fixed_nodes: List[SceneNode], *,  grid_arrangement: bool = False) -> None:
         """Arrange a set of nodes given a set of fixed nodes
 
         :param nodes: nodes that we have to place
         :param fixed_nodes: nodes that are placed in the arranger before finding spots for nodes
+        :param grid_arrangement: If set to true if objects are to be placed in a grid
         """
-
         min_offset = self.getBuildVolume().getEdgeDisallowedSize() + 2  # Allow for some rounding errors
-        job = ArrangeObjectsJob(nodes, fixed_nodes, min_offset = max(min_offset, 8))
+        job = ArrangeObjectsJob(nodes, fixed_nodes, min_offset = max(min_offset, 8), grid_arrange = grid_arrangement)
         job.start()
 
     @pyqtSlot()
@@ -1970,7 +1977,8 @@ class CuraApplication(QtApplication):
             if select_models_on_load:
                 Selection.add(node)
         try:
-            arrange(nodes_to_arrange, self.getBuildVolume(), fixed_nodes)
+            arranger = Nest2DArrange(nodes_to_arrange, self.getBuildVolume(), fixed_nodes)
+            arranger.arrange()
         except:
             Logger.logException("e", "Failed to arrange the models")
 

+ 13 - 11
cura/MultiplyObjectsJob.py

@@ -14,17 +14,19 @@ from UM.Operations.TranslateOperation import TranslateOperation
 from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
 from UM.Scene.SceneNode import SceneNode
 from UM.i18n import i18nCatalog
-from cura.Arranging.Nest2DArrange import arrange, createGroupOperationForArrange
+from cura.Arranging.GridArrange import GridArrange
+from cura.Arranging.Nest2DArrange import Nest2DArrange
 
 i18n_catalog = i18nCatalog("cura")
 
 
 class MultiplyObjectsJob(Job):
-    def __init__(self, objects, count, min_offset = 8):
+    def __init__(self, objects, count: int, min_offset: int = 8 ,* , grid_arrange: bool = False):
         super().__init__()
         self._objects = objects
-        self._count = count
-        self._min_offset = min_offset
+        self._count: int = count
+        self._min_offset: int = min_offset
+        self._grid_arrange: bool = grid_arrange
 
     def run(self) -> None:
         status_message = Message(i18n_catalog.i18nc("@info:status", "Multiplying and placing objects"), lifetime = 0,
@@ -39,7 +41,7 @@ class MultiplyObjectsJob(Job):
 
         root = scene.getRoot()
 
-        processed_nodes = []  # type: List[SceneNode]
+        processed_nodes: List[SceneNode] = []
         nodes = []
 
         fixed_nodes = []
@@ -76,12 +78,12 @@ class MultiplyObjectsJob(Job):
         found_solution_for_all = True
         group_operation = GroupedOperation()
         if nodes:
-            group_operation, not_fit_count = createGroupOperationForArrange(nodes,
-                                                                            Application.getInstance().getBuildVolume(),
-                                                                            fixed_nodes,
-                                                                            factor = 10000,
-                                                                            add_new_nodes_in_scene = True)
-            found_solution_for_all = not_fit_count == 0
+            if self._grid_arrange:
+                arranger = GridArrange(nodes, Application.getInstance().getBuildVolume(), fixed_nodes)
+            else:
+                arranger = Nest2DArrange(nodes, Application.getInstance().getBuildVolume(), fixed_nodes, factor=1000)
+
+            group_operation, not_fit_count = arranger.createGroupOperationForArrange(add_new_nodes_in_scene=True)
 
         if nodes_to_add_without_arrange:
             for nested_node in nodes_to_add_without_arrange:

+ 5 - 4
resources/qml/Actions.qml

@@ -41,7 +41,7 @@ Item
     property alias deleteAll: deleteAllAction
     property alias reloadAll: reloadAllAction
     property alias arrangeAll: arrangeAllAction
-    property alias arrangeSelection: arrangeSelectionAction
+    property alias arrangeAllGrid: arrangeAllGridAction
     property alias resetAllTranslation: resetAllTranslationAction
     property alias resetAll: resetAllAction
 
@@ -462,9 +462,10 @@ Item
 
     Action
     {
-        id: arrangeSelectionAction
-        text: catalog.i18nc("@action:inmenu menubar:edit","Arrange Selection")
-        onTriggered: Printer.arrangeSelection()
+        id: arrangeAllGridAction
+        text: catalog.i18nc("@action:inmenu menubar:edit","Arrange All Models in a grid")
+        onTriggered: Printer.arrangeAllInGrid()
+        shortcut: "Shift+Ctrl+R"
     }
 
     Action

+ 38 - 18
resources/qml/Menus/ContextMenu.qml

@@ -66,6 +66,7 @@ Cura.Menu
     Cura.MenuSeparator {}
     Cura.MenuItem { action: Cura.Actions.selectAll }
     Cura.MenuItem { action: Cura.Actions.arrangeAll }
+    Cura.MenuItem { action: Cura.Actions.arrangeAllGrid }
     Cura.MenuItem { action: Cura.Actions.deleteAll }
     Cura.MenuItem { action: Cura.Actions.reloadAll }
     Cura.MenuItem { action: Cura.Actions.resetAllTranslation }
@@ -108,9 +109,7 @@ Cura.Menu
         height: UM.Theme.getSize("small_popup_dialog").height
         minimumWidth: UM.Theme.getSize("small_popup_dialog").width
         minimumHeight: UM.Theme.getSize("small_popup_dialog").height
-
-        onAccepted: CuraActions.multiplySelection(copiesField.value)
-
+        onAccepted: gridPlacementSelected.checked? CuraActions.multiplySelectionToGrid(copiesField.value) : CuraActions.multiplySelection(copiesField.value)
         buttonSpacing: UM.Theme.getSize("thin_margin").width
 
         rightButtons:
@@ -127,28 +126,49 @@ Cura.Menu
             }
         ]
 
-        Row
+        Column
         {
-            spacing: UM.Theme.getSize("default_margin").width
+            spacing: UM.Theme.getSize("default_margin").height
 
-            UM.Label
+            Row
             {
-                text: catalog.i18nc("@label", "Number of Copies")
-                anchors.verticalCenter: copiesField.verticalCenter
-                width: contentWidth
-                wrapMode: Text.NoWrap
+                spacing: UM.Theme.getSize("default_margin").width
+
+                UM.Label
+                {
+                    text: catalog.i18nc("@label", "Number of Copies")
+                    anchors.verticalCenter: copiesField.verticalCenter
+                    width: contentWidth
+                    wrapMode: Text.NoWrap
+                }
+
+                Cura.SpinBox
+                {
+                    id: copiesField
+                    editable: true
+                    focus: true
+                    from: 1
+                    to: 99
+                    width: 2 * UM.Theme.getSize("button").width
+                    value: 1
+                }
             }
 
-            Cura.SpinBox
+            UM.CheckBox
             {
-                id: copiesField
-                editable: true
-                focus: true
-                from: 1
-                to: 99
-                width: 2 * UM.Theme.getSize("button").width
-                value: 1
+                id: gridPlacementSelected
+                text: catalog.i18nc("@label", "Grid Placement")
+
+                UM.ToolTip
+                {
+                    visible: parent.hovered
+                    targetPoint: Qt.point(parent.x + Math.round(parent.width / 2), parent.y)
+                    x: 0
+                    y: parent.y + parent.height + UM.Theme.getSize("default_margin").height
+                    tooltipText: catalog.i18nc("@info", "Multiply selected item and place them in a grid of build plate.")
+                }
             }
+
         }
     }
 }