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"\n") - ellipse = True - if ellipse: + if self._build_volume.getShape() == "elliptic": f.write( f""" """) else: @@ -197,30 +326,32 @@ class GridArrange(Arranger): /> """) - for grid_x in range(0, 100): - for grid_y in range(0, 100): - # if (grid_x, grid_y) in intersecting_grid_idx: - # fill_color = "red" - # elif (grid_x, grid_y) in build_plate_grid_idx: - # fill_color = "green" - # else: - # fill_color = "orange" + for grid_x in range(-10, 10): + for grid_y in range(-10, 10): + if (grid_x, grid_y) in self._allowed_grid_idx: + fill_color = "rgba(0, 255, 0, 0.5)" + elif (grid_x, grid_y) in self._build_plate_grid_ids: + fill_color = "rgba(255, 165, 0, 0.5)" + else: + fill_color = "rgba(255, 0, 0, 0.5)" - coord_grid_x, coord_grid_y = self.gridSpaceToCoordSpace(grid_x, grid_y) + coord_grid_x, coord_grid_y = self._gridSpaceToCoordSpace(grid_x, grid_y) f.write( f""" """) f.write(f""" @@ -238,24 +369,25 @@ class GridArrange(Arranger): fill="red" /> """) - for node in self._nodes_to_arrange: - bounding_box = node.getBoundingBox() - f.write(f""" - - """) - for x in range(math.floor(self._build_volume_bounding_box.left), math.floor(self._build_volume_bounding_box.right), 50): - for y in range(math.floor(self._build_volume_bounding_box.back), math.floor(self._build_volume_bounding_box.front), 50): - color = "green" if self.checkPointUnderDiscSpace(x, y) else "red" - f.write(f""" - - """) - 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"")