Merge pull request #8503 from Ultimaker/CURA-7440-pynest2d_for_arrange

Swap out arrange for PyNest2d
This commit is contained in:
Remco Burema 2020-10-13 11:54:00 +02:00 committed by GitHub
commit 534b8f74a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 216 additions and 156 deletions

View File

@ -2,6 +2,7 @@
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
from UM.Decorators import deprecated
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Logger import Logger
from UM.Math.Polygon import Polygon
@ -20,6 +21,7 @@ LocationSuggestion = namedtuple("LocationSuggestion", ["x", "y", "penalty_points
"""Return object for bestSpot"""
@deprecated("Use the functions in Nest2dArrange instead", "4.8")
class Arrange:
"""
The Arrange classed is used together with :py:class:`cura.Arranging.ShapeArray.ShapeArray`. Use it to find good locations for objects that you try to put
@ -44,6 +46,7 @@ class Arrange:
self._last_priority = 0
self._is_empty = True
@deprecated("Use the functions in Nest2dArrange instead", "4.8")
@classmethod
def create(cls, scene_root = None, fixed_nodes = None, scale = 0.5, x = 350, y = 250, min_offset = 8) -> "Arrange":
"""Helper to create an :py:class:`cura.Arranging.Arrange.Arrange` instance
@ -101,6 +104,7 @@ class Arrange:
self._last_priority = 0
@deprecated("Use the functions in Nest2dArrange instead", "4.8")
def findNodePlacement(self, node: SceneNode, offset_shape_arr: ShapeArray, hull_shape_arr: ShapeArray, step = 1) -> bool:
"""Find placement for a node (using offset shape) and place it (using hull shape)

View File

@ -1,24 +1,16 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import QCoreApplication
from typing import List
from UM.Application import Application
from UM.Job import Job
from UM.Scene.SceneNode import SceneNode
from UM.Math.Vector import Vector
from UM.Operations.TranslateOperation import TranslateOperation
from UM.Operations.GroupedOperation import GroupedOperation
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
i18n_catalog = i18nCatalog("cura")
from cura.Scene.ZOffsetDecorator import ZOffsetDecorator
from cura.Arranging.Arrange import Arrange
from cura.Arranging.ShapeArray import ShapeArray
from typing import List
class ArrangeObjectsJob(Job):
def __init__(self, nodes: List[SceneNode], fixed_nodes: List[SceneNode], min_offset = 8) -> None:
@ -34,76 +26,14 @@ class ArrangeObjectsJob(Job):
progress = 0,
title = i18n_catalog.i18nc("@info:title", "Finding Location"))
status_message.show()
global_container_stack = Application.getInstance().getGlobalContainerStack()
machine_width = global_container_stack.getProperty("machine_width", "value")
machine_depth = global_container_stack.getProperty("machine_depth", "value")
arranger = Arrange.create(x = machine_width, y = machine_depth, fixed_nodes = self._fixed_nodes, min_offset = self._min_offset)
# Build set to exclude children (those get arranged together with the parents).
included_as_child = set()
for node in self._nodes:
included_as_child.update(node.getAllChildren())
# Collect nodes to be placed
nodes_arr = [] # fill with (size, node, offset_shape_arr, hull_shape_arr)
for node in self._nodes:
if node in included_as_child:
continue
offset_shape_arr, hull_shape_arr = ShapeArray.fromNode(node, min_offset = self._min_offset, include_children = True)
if offset_shape_arr is None:
Logger.log("w", "Node [%s] could not be converted to an array for arranging...", str(node))
continue
nodes_arr.append((offset_shape_arr.arr.shape[0] * offset_shape_arr.arr.shape[1], node, offset_shape_arr, hull_shape_arr))
# Sort the nodes with the biggest area first.
nodes_arr.sort(key=lambda item: item[0])
nodes_arr.reverse()
# Place nodes one at a time
start_priority = 0
last_priority = start_priority
last_size = None
grouped_operation = GroupedOperation()
found_solution_for_all = True
not_fit_count = 0
for idx, (size, node, offset_shape_arr, hull_shape_arr) in enumerate(nodes_arr):
# For performance reasons, we assume that when a location does not fit,
# it will also not fit for the next object (while what can be untrue).
if last_size == size: # This optimization works if many of the objects have the same size
start_priority = last_priority
else:
start_priority = 0
best_spot = arranger.bestSpot(hull_shape_arr, start_prio = start_priority)
x, y = best_spot.x, best_spot.y
node.removeDecorator(ZOffsetDecorator)
if node.getBoundingBox():
center_y = node.getWorldPosition().y - node.getBoundingBox().bottom
else:
center_y = 0
if x is not None: # We could find a place
last_size = size
last_priority = best_spot.priority
arranger.place(x, y, offset_shape_arr) # take place before the next one
grouped_operation.addOperation(TranslateOperation(node, Vector(x, center_y, y), set_position = True))
else:
Logger.log("d", "Arrange all: could not find spot!")
found_solution_for_all = False
grouped_operation.addOperation(TranslateOperation(node, Vector(200, center_y, -not_fit_count * 20), set_position = True))
not_fit_count += 1
status_message.setProgress((idx + 1) / len(nodes_arr) * 100)
Job.yieldThread()
QCoreApplication.processEvents()
grouped_operation.push()
found_solution_for_all = arrange(self._nodes, Application.getInstance().getBuildVolume(), self._fixed_nodes)
status_message.hide()
if not found_solution_for_all:
no_full_solution_message = Message(i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"),
no_full_solution_message = Message(
i18n_catalog.i18nc("@info:status",
"Unable to find a location within the build volume for all objects"),
title = i18n_catalog.i18nc("@info:title", "Can't Find Location"))
no_full_solution_message.show()
self.finished.emit(self)

View File

@ -0,0 +1,140 @@
import numpy
from pynest2d import Point, Box, Item, NfpConfig, nest
from typing import List, TYPE_CHECKING, Optional, Tuple
from UM.Application import Application
from UM.Math.Matrix import Matrix
from UM.Math.Polygon import Polygon
from UM.Math.Quaternion import Quaternion
from UM.Math.Vector import Vector
from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
from UM.Operations.GroupedOperation import GroupedOperation
from UM.Operations.RotateOperation import RotateOperation
from UM.Operations.TranslateOperation import TranslateOperation
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
"""
machine_width = build_volume.getWidth()
machine_depth = build_volume.getDepth()
build_plate_bounding_box = Box(machine_width * factor, 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")
converted_points = []
for point in hull_polygon.getPoints():
converted_points.append(Point(point[0] * factor, 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: # numpy array has to be explicitly checked against None
for point in clipped_area.getPoints():
converted_points.append(Point(point[0] * factor, 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.getPoints() is not None: # numpy array has to be explicitly checked against None
for point in hull_polygon.getPoints():
converted_points.append(Point(point[0] * factor, point[1] * factor))
item = Item(converted_points)
item.markAsFixedInBin(0)
node_items.append(item)
num_disallowed_areas_added += 1
config = NfpConfig()
config.accuracy = 1.0
num_bins = nest(node_items, build_plate_bounding_box, 10000, 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 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
"""
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
grouped_operation.push()
return found_solution_for_all

View File

@ -180,12 +180,21 @@ class BuildVolume(SceneNode):
def setWidth(self, width: float) -> None:
self._width = width
def getWidth(self) -> float:
return self._width
def setHeight(self, height: float) -> None:
self._height = height
def getHeight(self) -> float:
return self._height
def setDepth(self, depth: float) -> None:
self._depth = depth
def getDepth(self) -> float:
return self._depth
def setShape(self, shape: str) -> None:
if shape:
self._shape = shape

View File

@ -36,6 +36,7 @@ from UM.Scene.Camera import Camera
from UM.Scene.GroupDecorator import GroupDecorator
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Scene.SceneNode import SceneNode
from UM.Scene.SceneNodeSettings import SceneNodeSettings
from UM.Scene.Selection import Selection
from UM.Scene.ToolHandle import ToolHandle
from UM.Settings.ContainerRegistry import ContainerRegistry
@ -52,7 +53,7 @@ from cura.API.Account import Account
from cura.Arranging.Arrange import Arrange
from cura.Arranging.ArrangeObjectsAllBuildPlatesJob import ArrangeObjectsAllBuildPlatesJob
from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob
from cura.Arranging.ShapeArray import ShapeArray
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
@ -801,6 +802,8 @@ class CuraApplication(QtApplication):
self._setLoadingHint(self._i18n_catalog.i18nc("@info:progress", "Initializing build volume..."))
root = self.getController().getScene().getRoot()
self._volume = BuildVolume.BuildVolume(self, root)
# Ensure that the old style arranger still works.
Arrange.build_volume = self._volume
# initialize info objects
@ -1379,6 +1382,7 @@ class CuraApplication(QtApplication):
def arrangeAll(self) -> None:
nodes_to_arrange = []
active_build_plate = self.getMultiBuildPlateModel().activeBuildPlate
locked_nodes = []
for node in DepthFirstIterator(self.getController().getScene().getRoot()):
if not isinstance(node, SceneNode):
continue
@ -1400,8 +1404,12 @@ class CuraApplication(QtApplication):
# Skip nodes that are too big
bounding_box = node.getBoundingBox()
if bounding_box is None or bounding_box.width < self._volume.getBoundingBox().width or bounding_box.depth < self._volume.getBoundingBox().depth:
# Arrange only the unlocked nodes and keep the locked ones in place
if UM.Util.parseBool(node.getSetting(SceneNodeSettings.LockPosition)):
locked_nodes.append(node)
else:
nodes_to_arrange.append(node)
self.arrange(nodes_to_arrange, fixed_nodes = [])
self.arrange(nodes_to_arrange, locked_nodes)
def arrange(self, nodes: List[SceneNode], fixed_nodes: List[SceneNode]) -> None:
"""Arrange a set of nodes given a set of fixed nodes
@ -1815,17 +1823,21 @@ class CuraApplication(QtApplication):
for node_ in DepthFirstIterator(root):
if node_.callDecoration("isSliceable") and node_.callDecoration("getBuildPlateNumber") == target_build_plate:
fixed_nodes.append(node_)
machine_width = global_container_stack.getProperty("machine_width", "value")
machine_depth = global_container_stack.getProperty("machine_depth", "value")
arranger = Arrange.create(x = machine_width, y = machine_depth, fixed_nodes = fixed_nodes)
min_offset = 8
default_extruder_position = self.getMachineManager().defaultExtruderPosition
default_extruder_id = self._global_container_stack.extruderList[int(default_extruder_position)].getId()
select_models_on_load = self.getPreferences().getValue("cura/select_models_on_load")
for original_node in nodes:
nodes_to_arrange = [] # type: List[CuraSceneNode]
fixed_nodes = []
for node_ in DepthFirstIterator(self.getController().getScene().getRoot()):
# Only count sliceable objects
if node_.callDecoration("isSliceable"):
fixed_nodes.append(node_)
for original_node in nodes:
# Create a CuraSceneNode just if the original node is not that type
if isinstance(original_node, CuraSceneNode):
node = original_node
@ -1834,7 +1846,7 @@ class CuraApplication(QtApplication):
node.setMeshData(original_node.getMeshData())
# Setting meshdata does not apply scaling.
if(original_node.getScale() != Vector(1.0, 1.0, 1.0)):
if original_node.getScale() != Vector(1.0, 1.0, 1.0):
node.scale(original_node.getScale())
node.setSelectable(True)
@ -1865,19 +1877,15 @@ class CuraApplication(QtApplication):
if file_extension != "3mf":
if node.callDecoration("isSliceable"):
# Only check position if it's not already blatantly obvious that it won't fit.
if node.getBoundingBox() is None or self._volume.getBoundingBox() is None or node.getBoundingBox().width < self._volume.getBoundingBox().width or node.getBoundingBox().depth < self._volume.getBoundingBox().depth:
# Find node location
offset_shape_arr, hull_shape_arr = ShapeArray.fromNode(node, min_offset = min_offset)
# Ensure that the bottom of the bounding box is on the build plate
if node.getBoundingBox():
center_y = node.getWorldPosition().y - node.getBoundingBox().bottom
else:
center_y = 0
# If a model is to small then it will not contain any points
if offset_shape_arr is None and hull_shape_arr is None:
Message(self._i18n_catalog.i18nc("@info:status", "The selected model was too small to load."),
title = self._i18n_catalog.i18nc("@info:title", "Warning")).show()
return
node.translate(Vector(0, center_y, 0))
# Step is for skipping tests to make it a lot faster. it also makes the outcome somewhat rougher
arranger.findNodePlacement(node, offset_shape_arr, hull_shape_arr, step = 10)
nodes_to_arrange.append(node)
# This node is deep copied from some other node which already has a BuildPlateDecorator, but the deepcopy
# of BuildPlateDecorator produces one that's associated with build plate -1. So, here we need to check if
@ -1897,6 +1905,8 @@ class CuraApplication(QtApplication):
if select_models_on_load:
Selection.add(node)
arrange(nodes_to_arrange, self.getBuildVolume(), fixed_nodes)
self.fileCompleted.emit(file_name)
def addNonSliceableExtension(self, extension):

View File

@ -4,21 +4,16 @@
import copy
from typing import List
from PyQt5.QtCore import QCoreApplication
from UM.Application import Application
from UM.Job import Job
from UM.Operations.GroupedOperation import GroupedOperation
from UM.Message import Message
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Scene.SceneNode import SceneNode
from UM.i18n import i18nCatalog
from cura.Arranging.Nest2DArrange import arrange
i18n_catalog = i18nCatalog("cura")
from cura.Arranging.Arrange import Arrange
from cura.Arranging.ShapeArray import ShapeArray
from UM.Application import Application
from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
class MultiplyObjectsJob(Job):
def __init__(self, objects, count, min_offset = 8):
@ -29,27 +24,26 @@ class MultiplyObjectsJob(Job):
def run(self) -> None:
status_message = Message(i18n_catalog.i18nc("@info:status", "Multiplying and placing objects"), lifetime = 0,
dismissable=False, progress=0, title = i18n_catalog.i18nc("@info:title", "Placing Objects"))
dismissable = False, progress = 0,
title = i18n_catalog.i18nc("@info:title", "Placing Objects"))
status_message.show()
scene = Application.getInstance().getController().getScene()
total_progress = len(self._objects) * self._count
current_progress = 0
global_container_stack = Application.getInstance().getGlobalContainerStack()
if global_container_stack is None:
return # We can't do anything in this case.
machine_width = global_container_stack.getProperty("machine_width", "value")
machine_depth = global_container_stack.getProperty("machine_depth", "value")
root = scene.getRoot()
scale = 0.5
arranger = Arrange.create(x = machine_width, y = machine_depth, scene_root = root, scale = scale, min_offset = self._min_offset)
processed_nodes = [] # type: List[SceneNode]
nodes = []
not_fit_count = 0
found_solution_for_all = False
fixed_nodes = []
for node_ in DepthFirstIterator(root):
# Only count sliceable objects
if node_.callDecoration("isSliceable"):
fixed_nodes.append(node_)
for node in self._objects:
# If object is part of a group, multiply group
current_node = node
@ -60,31 +54,8 @@ class MultiplyObjectsJob(Job):
continue
processed_nodes.append(current_node)
node_too_big = False
if node.getBoundingBox().width < machine_width or node.getBoundingBox().depth < machine_depth:
offset_shape_arr, hull_shape_arr = ShapeArray.fromNode(current_node, min_offset = self._min_offset, scale = scale)
else:
node_too_big = True
found_solution_for_all = True
arranger.resetLastPriority()
for _ in range(self._count):
# We do place the nodes one by one, as we want to yield in between.
new_node = copy.deepcopy(node)
solution_found = False
if not node_too_big:
if offset_shape_arr is not None and hull_shape_arr is not None:
solution_found = arranger.findNodePlacement(new_node, offset_shape_arr, hull_shape_arr)
else:
# The node has no shape, so no need to arrange it. The solution is simple: Do nothing.
solution_found = True
if node_too_big or not solution_found:
found_solution_for_all = False
new_location = new_node.getPosition()
new_location = new_location.set(z = - not_fit_count * 20)
new_node.setPosition(new_location)
not_fit_count += 1
# Same build plate
build_plate_number = current_node.callDecoration("getBuildPlateNumber")
@ -93,20 +64,15 @@ class MultiplyObjectsJob(Job):
child.callDecoration("setBuildPlateNumber", build_plate_number)
nodes.append(new_node)
current_progress += 1
status_message.setProgress((current_progress / total_progress) * 100)
QCoreApplication.processEvents()
Job.yieldThread()
QCoreApplication.processEvents()
Job.yieldThread()
found_solution_for_all = True
if nodes:
operation = GroupedOperation()
for new_node in nodes:
operation.addOperation(AddSceneNodeOperation(new_node, current_node.getParent()))
operation.push()
found_solution_for_all = arrange(nodes, Application.getInstance().getBuildVolume(), fixed_nodes,
factor = 10000, add_new_nodes_in_scene = True)
status_message.hide()
if not found_solution_for_all:
no_full_solution_message = Message(i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"), title = i18n_catalog.i18nc("@info:title", "Placing Object"))
no_full_solution_message = Message(
i18n_catalog.i18nc("@info:status", "Unable to find a location within the build volume for all objects"),
title = i18n_catalog.i18nc("@info:title", "Placing Object"))
no_full_solution_message.show()

View File

@ -269,7 +269,7 @@ class ConvexHullDecorator(SceneNodeDecorator):
if mesh is None:
return Polygon([]) # Node has no mesh data, so just return an empty Polygon.
world_transform = self._node.getWorldTransformation(copy = False)
world_transform = self._node.getWorldTransformation(copy = True)
# Check the cache
if mesh is self._2d_convex_hull_mesh and world_transform == self._2d_convex_hull_mesh_world_transform:

View File

@ -22,6 +22,7 @@ import os
# tries to create PyQt objects on a non-main thread.
import Arcus # @UnusedImport
import Savitar # @UnusedImport
import pynest2d # @UnusedImport
from PyQt5.QtNetwork import QSslConfiguration, QSslSocket