Merge pull request #16547 from Ultimaker/optimal_offset

Find optimal offset for grid arrange
This commit is contained in:
Saumya Jain 2023-08-24 11:56:31 +02:00 committed by GitHub
commit 300f3fa5db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -1,11 +1,11 @@
import math import math
from typing import List, TYPE_CHECKING, Optional, Tuple, Set from typing import List, TYPE_CHECKING, Tuple, Set
if TYPE_CHECKING: if TYPE_CHECKING:
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from cura.BuildVolume import BuildVolume
from UM.Application import Application from UM.Application import Application
from UM.Math import AxisAlignedBox
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
from UM.Operations.GroupedOperation import GroupedOperation from UM.Operations.GroupedOperation import GroupedOperation
@ -22,14 +22,25 @@ class GridArrange(Arranger):
self._build_volume_bounding_box = build_volume.getBoundingBox() self._build_volume_bounding_box = build_volume.getBoundingBox()
self._fixed_nodes = fixed_nodes self._fixed_nodes = fixed_nodes
self._offset_x: float = 10 self._margin_x: float = 1
self._offset_y: float = 10 self._margin_y: float = 1
self._grid_width = 0 self._grid_width = 0
self._grid_height = 0 self._grid_height = 0
for node in self._nodes_to_arrange: for node in self._nodes_to_arrange:
bounding_box = node.getBoundingBox() bounding_box = node.getBoundingBox()
self._grid_width = max(self._grid_width, bounding_box.width) self._grid_width = max(self._grid_width, bounding_box.width)
self._grid_height = max(self._grid_height, bounding_box.depth) 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_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 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_x = math.floor(self._initial_leftover_grid_x)
self._initial_leftover_grid_y = math.floor(self._initial_leftover_grid_y) 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 # 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: 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 # Filter out the corner grid squares if the build plate shape is elliptic
if self._build_volume.getShape() == "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 # 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_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 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) 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: sequence: List[Tuple[int, int]] = list(self._allowed_grid_idx)
grid_x, grid_y = grid_id sequence.sort(key=lambda grid_id: (grid_build_plate_center_x - grid_id[0]) ** 2 + (
distance_squared = (grid_build_plate_center_x - grid_x) ** 2 + (grid_build_plate_center_y - grid_y) ** 2 grid_build_plate_center_y - grid_id[1]) ** 2)
return distance_squared
sequence: List[Tuple[int, int]] = list(allowed_grid_idx)
sequence.sort(key=distToCenter)
scene_root = Application.getInstance().getController().getScene().getRoot() scene_root = Application.getInstance().getController().getScene().getRoot()
grouped_operation = GroupedOperation() grouped_operation = GroupedOperation()
@ -70,7 +80,7 @@ class GridArrange(Arranger):
if add_new_nodes_in_scene: if add_new_nodes_in_scene:
grouped_operation.addOperation(AddSceneNodeOperation(node, scene_root)) grouped_operation.addOperation(AddSceneNodeOperation(node, scene_root))
grid_x, grid_y = grid_id 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) grouped_operation.addOperation(operation)
leftover_nodes = self._nodes_to_arrange[len(sequence):] leftover_nodes = self._nodes_to_arrange[len(sequence):]
@ -80,18 +90,138 @@ class GridArrange(Arranger):
if add_new_nodes_in_scene: if add_new_nodes_in_scene:
grouped_operation.addOperation(AddSceneNodeOperation(node, scene_root)) grouped_operation.addOperation(AddSceneNodeOperation(node, scene_root))
# find the first next grid position that isn't occupied by a fixed node # 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 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) grouped_operation.addOperation(operation)
left_over_grid_y = left_over_grid_y - 1 left_over_grid_y = left_over_grid_y - 1
return grouped_operation, len(leftover_nodes) return grouped_operation, len(leftover_nodes)
def moveNodeOnGrid(self, node: "SceneNode", grid_x: int, grid_y: int) -> "Operation.Operation": def _findOptimalGridOffset(self):
coord_grid_x, coord_grid_y = self.gridSpaceToCoordSpace(grid_x, grid_y) if len(self._fixed_nodes) == 0:
center_grid_x = coord_grid_x + (0.5 * (self._grid_width + self._offset_x)) self._offset_x = 0
center_grid_y = coord_grid_y + (0.5 * (self._grid_height + self._offset_y)) 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() bounding_box = node.getBoundingBox()
center_node_x = (bounding_box.left + bounding_box.right) * 0.5 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)) 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_x1 = bounding_box.left
coord_x2 = bounding_box.right coord_x2 = bounding_box.right
coord_y1 = bounding_box.back coord_y1 = bounding_box.back
@ -112,7 +242,7 @@ class GridArrange(Arranger):
return grid_x1, grid_y1, grid_x2, grid_y2 return grid_x1, grid_y1, grid_x2, grid_y2
def intersectingGridIdxInclusive(self, bounding_box: "BoundingVolume") -> Set[Tuple[int, int]]: 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() grid_idx = set()
for grid_x in range(math.floor(grid_x1), math.ceil(grid_x2)): 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)): for grid_y in range(math.floor(grid_y1), math.ceil(grid_y2)):
@ -120,26 +250,26 @@ class GridArrange(Arranger):
return grid_idx return grid_idx
def intersectingGridIdxExclusive(self, bounding_box: "BoundingVolume") -> Set[Tuple[int, int]]: 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() grid_idx = set()
for grid_x in range(math.ceil(grid_x1), math.floor(grid_x2)): 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)): for grid_y in range(math.ceil(grid_y1), math.floor(grid_y2)):
grid_idx.add((grid_x, grid_y)) grid_idx.add((grid_x, grid_y))
return grid_idx return grid_idx
def gridSpaceToCoordSpace(self, x: float, y: float) -> Tuple[float, float]: 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_x = x * self._grid_width + self._build_volume_bounding_box.left + self._offset_x
grid_y = y * (self._grid_height + self._offset_y) + self._build_volume_bounding_box.back grid_y = y * self._grid_height + self._build_volume_bounding_box.back + self._offset_y
return grid_x, grid_y return grid_x, grid_y
def coordSpaceToGridSpace(self, grid_x: float, grid_y: float) -> Tuple[float, float]: 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_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._grid_height + self._offset_y) coord_y = (grid_y - self._build_volume_bounding_box.back - self._offset_y) / self._grid_height
return coord_x, coord_y return coord_x, coord_y
def checkGridUnderDiscSpace(self, grid_x: int, grid_y: int) -> bool: def checkGridUnderDiscSpace(self, grid_x: int, grid_y: int) -> bool:
left, back = self.gridSpaceToCoordSpace(grid_x, grid_y) left, back = self._gridSpaceToCoordSpace(grid_x, grid_y)
right, front = self.gridSpaceToCoordSpace(grid_x + 1, grid_y + 1) right, front = self._gridSpaceToCoordSpace(grid_x + 1, grid_y + 1)
corners = [(left, back), (right, back), (right, front), (left, front)] corners = [(left, back), (right, back), (right, front), (left, front)]
return all([self.checkPointUnderDiscSpace(x, y) for x, y in corners]) 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 disc_y = ((y - self._build_volume_bounding_box.back) / self._build_volume_bounding_box.depth) * 2.0 - 1.0
return disc_x, disc_y return disc_x, disc_y
def drawDebugSvg(self): def _drawDebugSvg(self):
with open("Builvolume_test.svg", "w") as f: with open("Builvolume_test.svg", "w") as f:
build_volume_bounding_box = self._build_volume_bounding_box build_volume_bounding_box = self._build_volume_bounding_box
f.write( f.write(
f"<svg xmlns='http://www.w3.org/2000/svg' viewBox='{build_volume_bounding_box.left - 100} {build_volume_bounding_box.back - 100} {build_volume_bounding_box.width + 200} {build_volume_bounding_box.depth + 200}'>\n") f"<svg xmlns='http://www.w3.org/2000/svg' viewBox='{build_volume_bounding_box.left - 100} {build_volume_bounding_box.back - 100} {build_volume_bounding_box.width + 200} {build_volume_bounding_box.depth + 200}'>\n")
ellipse = True if self._build_volume.getShape() == "elliptic":
if ellipse:
f.write( f.write(
f""" f"""
<ellipse <ellipse
@ -182,7 +311,7 @@ class GridArrange(Arranger):
cy='{(build_volume_bounding_box.back + build_volume_bounding_box.front) * 0.5}' cy='{(build_volume_bounding_box.back + build_volume_bounding_box.front) * 0.5}'
rx='{build_volume_bounding_box.width * 0.5}' rx='{build_volume_bounding_box.width * 0.5}'
ry='{build_volume_bounding_box.depth * 0.5}' ry='{build_volume_bounding_box.depth * 0.5}'
fill=\"blue\" fill=\"lightgrey\"
/> />
""") """)
else: else:
@ -197,30 +326,32 @@ class GridArrange(Arranger):
/> />
""") """)
for grid_x in range(0, 100): for grid_x in range(-10, 10):
for grid_y in range(0, 100): for grid_y in range(-10, 10):
# if (grid_x, grid_y) in intersecting_grid_idx: if (grid_x, grid_y) in self._allowed_grid_idx:
# fill_color = "red" fill_color = "rgba(0, 255, 0, 0.5)"
# elif (grid_x, grid_y) in build_plate_grid_idx: elif (grid_x, grid_y) in self._build_plate_grid_ids:
# fill_color = "green" fill_color = "rgba(255, 165, 0, 0.5)"
# else: else:
# fill_color = "orange" 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.write(
f""" f"""
<rect <rect
x="{coord_grid_x}" x="{coord_grid_x + self._margin_x * 0.5}"
y="{coord_grid_y}" y="{coord_grid_y + self._margin_y * 0.5}"
width="{self._grid_width}" width="{self._grid_width - self._margin_x}"
height="{self._grid_height}" height="{self._grid_height - self._margin_y}"
fill="#ff00ff88" fill="{fill_color}"
stroke="black" stroke="black"
/> />
""") """)
f.write(f""" f.write(f"""
<text <text
font-size="8" font-size="4"
text-anchor="middle"
alignment-baseline="middle"
x="{coord_grid_x + self._grid_width * 0.5}" x="{coord_grid_x + self._grid_width * 0.5}"
y="{coord_grid_y + self._grid_height * 0.5}" y="{coord_grid_y + self._grid_height * 0.5}"
> >
@ -238,24 +369,25 @@ class GridArrange(Arranger):
fill="red" fill="red"
/> />
""") """)
for node in self._nodes_to_arrange:
bounding_box = node.getBoundingBox()
f.write(f"""
<rect
x="{bounding_box.left}"
y="{bounding_box.back}"
width="{bounding_box.width}"
height="{bounding_box.depth}"
fill="rgba(0,0,0,0.1)"
stroke="blue"
stroke-width="3"
/>
""")
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"""
<circle cx="{x}" cy="{y}" r="10" fill="{color}" /> <circle
""") cx="{self._offset_x}"
cy="{self._offset_y}"
r="2"
stroke="red"
fill="none"
/>""")
# 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"""
# <circle
# cx="{coord_build_plate_center_x}"
# cy="{coord_build_plate_center_y}"
# r="2"
# stroke="blue"
# fill="none"
# />""")
f.write(f"</svg>") f.write(f"</svg>")