diff --git a/cura/Arranging/GridArrange.py b/cura/Arranging/GridArrange.py
index c430de4d73..3b00f44ddc 100644
--- a/cura/Arranging/GridArrange.py
+++ b/cura/Arranging/GridArrange.py
@@ -1,11 +1,11 @@
import math
-from typing import List, TYPE_CHECKING, Optional, Tuple, Set
+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 import AxisAlignedBox
from UM.Math.Vector import Vector
from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
from UM.Operations.GroupedOperation import GroupedOperation
@@ -22,14 +22,25 @@ class GridArrange(Arranger):
self._build_volume_bounding_box = build_volume.getBoundingBox()
self._fixed_nodes = fixed_nodes
- self._offset_x: float = 10
- self._offset_y: float = 10
+ 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
+ self._grid_width = math.ceil(self._grid_width / 10) * 10
+ self._grid_height = math.ceil(self._grid_height / 10) * 10
+
+ 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
@@ -37,32 +48,31 @@ class GridArrange(Arranger):
self._initial_leftover_grid_x = math.floor(self._initial_leftover_grid_x)
self._initial_leftover_grid_y = math.floor(self._initial_leftover_grid_y)
- def createGroupOperationForArrange(self, add_new_nodes_in_scene: bool = False) -> Tuple[GroupedOperation, int]:
# Find grid indexes that intersect with fixed objects
- fixed_nodes_grid_ids = set()
+ self._fixed_nodes_grid_ids = set()
for node in self._fixed_nodes:
- fixed_nodes_grid_ids = fixed_nodes_grid_ids.union(self.intersectingGridIdxInclusive(node.getBoundingBox()))
+ self._fixed_nodes_grid_ids = self._fixed_nodes_grid_ids.union(
+ self.intersectingGridIdxInclusive(node.getBoundingBox()))
- build_plate_grid_ids = self.intersectingGridIdxExclusive(self._build_volume_bounding_box)
+ 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":
- build_plate_grid_ids = set(filter(lambda grid_id: self.checkGridUnderDiscSpace(grid_id[0], grid_id[1]), build_plate_grid_ids))
+ self._build_plate_grid_ids = set(
+ filter(lambda grid_id: self.checkGridUnderDiscSpace(grid_id[0], grid_id[1]),
+ self._build_plate_grid_ids))
- allowed_grid_idx = build_plate_grid_ids.difference(fixed_nodes_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)
- def distToCenter(grid_id: Tuple[int, int]) -> float:
- grid_x, grid_y = grid_id
- distance_squared = (grid_build_plate_center_x - grid_x) ** 2 + (grid_build_plate_center_y - grid_y) ** 2
- return distance_squared
-
- sequence: List[Tuple[int, int]] = list(allowed_grid_idx)
- sequence.sort(key=distToCenter)
+ 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()
@@ -70,7 +80,7 @@ class GridArrange(Arranger):
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)
+ operation = self._moveNodeOnGrid(node, grid_x, grid_y)
grouped_operation.addOperation(operation)
leftover_nodes = self._nodes_to_arrange[len(sequence):]
@@ -80,18 +90,138 @@ class GridArrange(Arranger):
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 fixed_nodes_grid_ids:
+ 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)
+ 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 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 + self._offset_x))
- center_grid_y = coord_grid_y + (0.5 * (self._grid_height + self._offset_y))
+ 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 food print │ NODE │
+ # └────────────────┘
+ # │ grid 1 │ grid 2 │ grid 3 │ grid 4 | grid 5 |
+ # ┌────────────────┐
+ # maximum food 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
@@ -102,7 +232,7 @@ class GridArrange(Arranger):
return TranslateOperation(node, Vector(delta_x, 0, delta_y))
- def getGridCornerPoints(self, bounding_box: "BoundingVolume") -> Tuple[float, float, float, float]:
+ 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
@@ -112,7 +242,7 @@ class GridArrange(Arranger):
return grid_x1, grid_y1, grid_x2, grid_y2
def intersectingGridIdxInclusive(self, bounding_box: "BoundingVolume") -> Set[Tuple[int, int]]:
- grid_x1, grid_y1, grid_x2, grid_y2 = self.getGridCornerPoints(bounding_box)
+ 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)):
@@ -120,26 +250,26 @@ class GridArrange(Arranger):
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_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._offset_x) + self._build_volume_bounding_box.left
- grid_y = y * (self._grid_height + self._offset_y) + self._build_volume_bounding_box.back
+ 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._grid_width + self._offset_x)
- coord_y = (grid_y - self._build_volume_bounding_box.back) / (self._grid_height + self._offset_y)
+ 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)
+ 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])
@@ -166,15 +296,14 @@ class GridArrange(Arranger):
disc_y = ((y - self._build_volume_bounding_box.back) / self._build_volume_bounding_box.depth) * 2.0 - 1.0
return disc_x, disc_y
- def drawDebugSvg(self):
+ def _drawDebugSvg(self):
with open("Builvolume_test.svg", "w") as f:
build_volume_bounding_box = self._build_volume_bounding_box
f.write(
f"")
\ No newline at end of file
+ f.write(f"""
+ """)
+
+ # 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
+ # f.write(f"""
+ # """)
+
+ f.write(f"")