Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/addons/binpacking.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

717 lines
21 KiB
Python

# Source package: "py3dbp" hosted on PyPI
# (c) Enzo Ruiz Pelaez
# https://github.com/enzoruiz/3dbinpacking
# License: MIT License
# Credits:
# - https://github.com/enzoruiz/3dbinpacking/blob/master/erick_dube_507-034.pdf
# - https://github.com/gedex/bp3d - implementation in Go
# - https://github.com/bom-d-van/binpacking - implementation in Go
#
# ezdxf add-on:
# License: MIT License
# (c) 2022, Manfred Moitzi:
# - refactoring
# - type annotations
# - adaptations:
# - removing Decimal class usage
# - utilizing ezdxf.math.BoundingBox for intersection checks
# - removed non-distributing mode; copy packer and use different bins for each copy
# - additions:
# - Item.get_transformation()
# - shuffle_pack()
# - pack_item_subset()
# - DXF exporter for debugging
from __future__ import annotations
from typing import (
Iterable,
TYPE_CHECKING,
TypeVar,
Optional,
)
from enum import Enum, auto
import copy
import math
import random
from ezdxf.enums import TextEntityAlignment
from ezdxf.math import (
Vec2,
Vec3,
UVec,
BoundingBox,
BoundingBox2d,
AbstractBoundingBox,
Matrix44,
)
from . import genetic_algorithm as ga
if TYPE_CHECKING:
from ezdxf.eztypes import GenericLayoutType
__all__ = [
"Item",
"FlatItem",
"Box", # contains Item
"Envelope", # contains FlatItem
"AbstractPacker",
"Packer",
"FlatPacker",
"RotationType",
"PickStrategy",
"shuffle_pack",
"pack_item_subset",
"export_dxf",
]
UNLIMITED_WEIGHT = 1e99
T = TypeVar("T")
PI_2 = math.pi / 2
class RotationType(Enum):
"""Rotation type of an item:
- W = width
- H = height
- D = depth
"""
WHD = auto()
HWD = auto()
HDW = auto()
DHW = auto()
DWH = auto()
WDH = auto()
class Axis(Enum):
WIDTH = auto()
HEIGHT = auto()
DEPTH = auto()
class PickStrategy(Enum):
"""Order of how to pick items for placement."""
SMALLER_FIRST = auto()
BIGGER_FIRST = auto()
SHUFFLE = auto()
START_POSITION: tuple[float, float, float] = (0, 0, 0)
class Item:
"""3D container item."""
def __init__(
self,
payload,
width: float,
height: float,
depth: float,
weight: float = 0.0,
):
self.payload = payload # arbitrary associated Python object
self.width = float(width)
self.height = float(height)
self.depth = float(depth)
self.weight = float(weight)
self._rotation_type = RotationType.WHD
self._position = START_POSITION
self._bbox: Optional[AbstractBoundingBox] = None
def __str__(self):
return (
f"{str(self.payload)}({self.width}x{self.height}x{self.depth}, "
f"weight: {self.weight}) pos({str(self.position)}) "
f"rt({self.rotation_type}) vol({self.get_volume()})"
)
def copy(self):
"""Returns a copy, all copies have a reference to the same payload
object.
"""
return copy.copy(self) # shallow copy
@property
def bbox(self) -> AbstractBoundingBox:
if self._bbox is None:
self._update_bbox()
return self._bbox # type: ignore
def _update_bbox(self) -> None:
v1 = Vec3(self._position)
self._bbox = BoundingBox([v1, v1 + Vec3(self.get_dimension())])
def _taint(self):
self._bbox = None
@property
def rotation_type(self) -> RotationType:
return self._rotation_type
@rotation_type.setter
def rotation_type(self, value: RotationType) -> None:
self._rotation_type = value
self._taint()
@property
def position(self) -> tuple[float, float, float]:
"""Returns the position of then lower left corner of the item in the
container, the lower left corner is the origin (0, 0, 0).
"""
return self._position
@position.setter
def position(self, value: tuple[float, float, float]) -> None:
self._position = value
self._taint()
def get_volume(self) -> float:
"""Returns the volume of the item."""
return self.width * self.height * self.depth
def get_dimension(self) -> tuple[float, float, float]:
"""Returns the item dimension according the :attr:`rotation_type`."""
rt = self.rotation_type
if rt == RotationType.WHD:
return self.width, self.height, self.depth
elif rt == RotationType.HWD:
return self.height, self.width, self.depth
elif rt == RotationType.HDW:
return self.height, self.depth, self.width
elif rt == RotationType.DHW:
return self.depth, self.height, self.width
elif rt == RotationType.DWH:
return self.depth, self.width, self.height
elif rt == RotationType.WDH:
return self.width, self.depth, self.height
raise ValueError(rt)
def get_transformation(self) -> Matrix44:
"""Returns the transformation matrix to transform the source entity
located with the minimum extension corner of its bounding box in
(0, 0, 0) to the final location including the required rotation.
"""
x, y, z = self.position
rt = self.rotation_type
if rt == RotationType.WHD: # width, height, depth
return Matrix44.translate(x, y, z)
if rt == RotationType.HWD: # height, width, depth
return Matrix44.z_rotate(PI_2) @ Matrix44.translate(
x + self.height, y, z
)
if rt == RotationType.HDW: # height, depth, width
return Matrix44.xyz_rotate(PI_2, 0, PI_2) @ Matrix44.translate(
x + self.height, y + self.depth, z
)
if rt == RotationType.DHW: # depth, height, width
return Matrix44.y_rotate(-PI_2) @ Matrix44.translate(
x + self.depth, y, z
)
if rt == RotationType.DWH: # depth, width, height
return Matrix44.xyz_rotate(0, PI_2, PI_2) @ Matrix44.translate(
x, y, z
)
if rt == RotationType.WDH: # width, depth, height
return Matrix44.x_rotate(PI_2) @ Matrix44.translate(
x, y + self.depth, z
)
raise TypeError(rt)
class FlatItem(Item):
"""2D container item, inherited from :class:`Item`. Has a default depth of
1.0.
"""
def __init__(
self,
payload,
width: float,
height: float,
weight: float = 0.0,
):
super().__init__(payload, width, height, 1.0, weight)
def _update_bbox(self) -> None:
v1 = Vec2(self._position)
self._bbox = BoundingBox2d([v1, v1 + Vec2(self.get_dimension())])
def __str__(self):
return (
f"{str(self.payload)}({self.width}x{self.height}, "
f"weight: {self.weight}) pos({str(self.position)}) "
f"rt({self.rotation_type}) area({self.get_volume()})"
)
class Bin:
def __init__(
self,
name,
width: float,
height: float,
depth: float,
max_weight: float = UNLIMITED_WEIGHT,
):
self.name = name
self.width = float(width)
if self.width <= 0.0:
raise ValueError("invalid width")
self.height = float(height)
if self.height <= 0.0:
raise ValueError("invalid height")
self.depth = float(depth)
if self.depth <= 0.0:
raise ValueError("invalid depth")
self.max_weight = float(max_weight)
self.items: list[Item] = []
def __len__(self):
return len(self.items)
def __iter__(self):
return iter(self.items)
def copy(self):
"""Returns a copy."""
box = copy.copy(self) # shallow copy
box.items = list(self.items)
return box
def reset(self):
"""Reset the container to empty state."""
self.items.clear()
@property
def is_empty(self) -> bool:
return not len(self.items)
def __str__(self) -> str:
return (
f"{str(self.name)}({self.width:.3f}x{self.height:.3f}x{self.depth:.3f}, "
f"max_weight:{self.max_weight}) "
f"vol({self.get_capacity():.3f})"
)
def put_item(self, item: Item, pivot: tuple[float, float, float]) -> bool:
valid_item_position = item.position
item.position = pivot
x, y, z = pivot
# Try all possible rotations:
for rotation_type in self.rotations():
item.rotation_type = rotation_type
w, h, d = item.get_dimension()
if self.width < x + w or self.height < y + h or self.depth < z + d:
continue
# new item fits inside the box at he current location and rotation:
item_bbox = item.bbox
if (
not any(item_bbox.has_intersection(i.bbox) for i in self.items)
and self.get_total_weight() + item.weight <= self.max_weight
):
self.items.append(item)
return True
item.position = valid_item_position
return False
def get_capacity(self) -> float:
"""Returns the maximum fill volume of the bin."""
return self.width * self.height * self.depth
def get_total_weight(self) -> float:
"""Returns the total weight of all fitted items."""
return sum(item.weight for item in self.items)
def get_total_volume(self) -> float:
"""Returns the total volume of all fitted items."""
return sum(item.get_volume() for item in self.items)
def get_fill_ratio(self) -> float:
"""Return the fill ratio."""
try:
return self.get_total_volume() / self.get_capacity()
except ZeroDivisionError:
return 0.0
def rotations(self) -> Iterable[RotationType]:
return RotationType
class Box(Bin):
"""3D container inherited from :class:`Bin`."""
pass
class Envelope(Bin):
"""2D container inherited from :class:`Bin`."""
def __init__(
self,
name,
width: float,
height: float,
max_weight: float = UNLIMITED_WEIGHT,
):
super().__init__(name, width, height, 1.0, max_weight)
def __str__(self) -> str:
return (
f"{str(self.name)}({self.width:.3f}x{self.height:.3f}, "
f"max_weight:{self.max_weight}) "
f"area({self.get_capacity():.3f})"
)
def rotations(self) -> Iterable[RotationType]:
return RotationType.WHD, RotationType.HWD
def _smaller_first(bins: list, items: list) -> None:
# SMALLER_FIRST is often very bad! Especially for many in small
# amounts increasing sizes.
bins.sort(key=lambda b: b.get_capacity())
items.sort(key=lambda i: i.get_volume())
def _bigger_first(bins: list, items: list) -> None:
# BIGGER_FIRST is the best strategy
bins.sort(key=lambda b: b.get_capacity(), reverse=True)
items.sort(key=lambda i: i.get_volume(), reverse=True)
def _shuffle(bins: list, items: list) -> None:
# Better as SMALLER_FIRST
random.shuffle(bins)
random.shuffle(items)
PICK_STRATEGY = {
PickStrategy.SMALLER_FIRST: _smaller_first,
PickStrategy.BIGGER_FIRST: _bigger_first,
PickStrategy.SHUFFLE: _shuffle,
}
class AbstractPacker:
def __init__(self) -> None:
self.bins: list[Bin] = []
self.items: list[Item] = []
self._init_state = True
def copy(self):
"""Copy packer in init state to apply different pack strategies."""
if self.is_packed:
raise TypeError("cannot copy packed state")
if not all(box.is_empty for box in self.bins):
raise TypeError("bins contain data in unpacked state")
packer = self.__class__()
packer.bins = [box.copy() for box in self.bins]
packer.items = [item.copy() for item in self.items]
return packer
@property
def is_packed(self) -> bool:
"""Returns ``True`` if packer is packed, each packer can only be used
once.
"""
return not self._init_state
@property
def unfitted_items(self) -> list[Item]: # just an alias
"""Returns the unfitted items."""
return self.items
def __str__(self) -> str:
fill = ""
if self.is_packed:
fill = f", fill ratio: {self.get_fill_ratio()}"
return f"{self.__class__.__name__}, {len(self.bins)} bins{fill}"
def append_bin(self, box: Bin) -> None:
"""Append a container."""
if self.is_packed:
raise TypeError("cannot append bins to packed state")
if not box.is_empty:
raise TypeError("cannot append bins with content")
self.bins.append(box)
def append_item(self, item: Item) -> None:
"""Append a item."""
if self.is_packed:
raise TypeError("cannot append items to packed state")
self.items.append(item)
def get_fill_ratio(self) -> float:
"""Return the fill ratio of all bins."""
total_capacity = self.get_capacity()
if total_capacity == 0.0:
return 0.0
return self.get_total_volume() / total_capacity
def get_capacity(self) -> float:
"""Returns the maximum fill volume of all bins."""
return sum(box.get_capacity() for box in self.bins)
def get_total_weight(self) -> float:
"""Returns the total weight of all fitted items in all bins."""
return sum(box.get_total_weight() for box in self.bins)
def get_total_volume(self) -> float:
"""Returns the total volume of all fitted items in all bins."""
return sum(box.get_total_volume() for box in self.bins)
def get_unfitted_volume(self) -> float:
"""Returns the total volume of all unfitted items."""
return sum(item.get_volume() for item in self.items)
def pack(self, pick=PickStrategy.BIGGER_FIRST) -> None:
"""Pack items into bins. Distributes all items across all bins."""
PICK_STRATEGY[pick](self.bins, self.items)
# items are removed from self.items while packing!
self._pack(self.bins, list(self.items))
# unfitted items remain in self.items
def _pack(self, bins: Iterable[Bin], items: Iterable[Item]) -> None:
"""Pack items into bins, removes packed items from self.items!"""
self._init_state = False
for box in bins:
for item in items:
if self.pack_to_bin(box, item):
self.items.remove(item)
# unfitted items remain in self.items
def pack_to_bin(self, box: Bin, item: Item) -> bool:
if not box.items:
return box.put_item(item, START_POSITION)
for axis in self._axis():
for placed_item in box.items:
w, h, d = placed_item.get_dimension()
x, y, z = placed_item.position
if axis == Axis.WIDTH:
pivot = (x + w, y, z) # new item right of the placed item
elif axis == Axis.HEIGHT:
pivot = (x, y + h, z) # new item above of the placed item
elif axis == Axis.DEPTH:
pivot = (x, y, z + d) # new item on top of the placed item
else:
raise TypeError(axis)
if box.put_item(item, pivot):
return True
return False
@staticmethod
def _axis() -> Iterable[Axis]:
return Axis
def shuffle_pack(packer: AbstractPacker, attempts: int) -> AbstractPacker:
"""Random shuffle packing. Returns a new packer with the best packing result,
the input packer is unchanged.
"""
if attempts < 1:
raise ValueError("expected attempts >= 1")
best_ratio = 0.0
best_packer = packer
for _ in range(attempts):
new_packer = packer.copy()
new_packer.pack(PickStrategy.SHUFFLE)
new_ratio = new_packer.get_fill_ratio()
if new_ratio > best_ratio:
best_ratio = new_ratio
best_packer = new_packer
return best_packer
def pack_item_subset(
packer: AbstractPacker, picker: Iterable, strategy=PickStrategy.BIGGER_FIRST
) -> None:
"""Pack a subset of `packer.items`, which are chosen by an iterable
yielding a True or False value for each item.
"""
assert packer.is_packed is False
chosen, rejects = get_item_subset(packer.items, picker)
packer.items = chosen
packer.pack(strategy) # unfitted items remain in packer.items
packer.items.extend(rejects) # append rejects as unfitted items
def get_item_subset(
items: list[Item], picker: Iterable
) -> tuple[list[Item], list[Item]]:
"""Returns a subset of `items`, where items are chosen by an iterable
yielding a True or False value for each item.
"""
chosen: list[Item] = []
rejects: list[Item] = []
count = 0
for item, pick in zip(items, picker):
count += 1
if pick:
chosen.append(item)
else:
rejects.append(item)
if count < len(items): # too few pick values given
rejects.extend(items[count:])
return chosen, rejects
class SubSetEvaluator(ga.Evaluator):
def __init__(self, packer: AbstractPacker):
self.packer = packer
def evaluate(self, dna: ga.DNA) -> float:
packer = self.run_packer(dna)
return packer.get_fill_ratio()
def run_packer(self, dna: ga.DNA) -> AbstractPacker:
packer = self.packer.copy()
pack_item_subset(packer, dna)
return packer
class Packer(AbstractPacker):
"""3D Packer inherited from :class:`AbstractPacker`."""
def add_bin(
self,
name: str,
width: float,
height: float,
depth: float,
max_weight: float = UNLIMITED_WEIGHT,
) -> Box:
"""Add a 3D :class:`Box` container."""
box = Box(name, width, height, depth, max_weight)
self.append_bin(box)
return box
def add_item(
self,
payload,
width: float,
height: float,
depth: float,
weight: float = 0.0,
) -> Item:
"""Add a 3D :class:`Item` to pack."""
item = Item(payload, width, height, depth, weight)
self.append_item(item)
return item
class FlatPacker(AbstractPacker):
"""2D Packer inherited from :class:`AbstractPacker`. All containers and
items used by this packer must have a depth of 1."""
def add_bin(
self,
name: str,
width: float,
height: float,
max_weight: float = UNLIMITED_WEIGHT,
) -> Envelope:
"""Add a 2D :class:`Envelope` container."""
envelope = Envelope(name, width, height, max_weight)
self.append_bin(envelope)
return envelope
def add_item(
self,
payload,
width: float,
height: float,
weight: float = 0.0,
) -> Item:
"""Add a 2D :class:`FlatItem` to pack."""
item = FlatItem(payload, width, height, weight)
self.append_item(item)
return item
@staticmethod
def _axis() -> Iterable[Axis]:
return Axis.WIDTH, Axis.HEIGHT
def export_dxf(
layout: "GenericLayoutType", bins: list[Bin], offset: UVec = (1, 0, 0)
) -> None:
from ezdxf import colors
offset_vec = Vec3(offset)
start = Vec3()
index = 0
rgb = (colors.RED, colors.GREEN, colors.BLUE, colors.MAGENTA, colors.CYAN)
for box in bins:
m = Matrix44.translate(start.x, start.y, start.z)
_add_frame(layout, box, "FRAME", m)
for item in box.items:
_add_mesh(layout, item, "ITEMS", rgb[index], m)
index += 1
if index >= len(rgb):
index = 0
start += offset_vec
def _add_frame(layout: "GenericLayoutType", box: Bin, layer: str, m: Matrix44):
def add_line(v1, v2):
line = layout.add_line(v1, v2, dxfattribs=attribs)
line.transform(m)
attribs = {"layer": layer}
x0, y0, z0 = (0.0, 0.0, 0.0)
x1 = float(box.width)
y1 = float(box.height)
z1 = float(box.depth)
corners = [
(x0, y0),
(x1, y0),
(x1, y1),
(x0, y1),
(x0, y0),
]
for (sx, sy), (ex, ey) in zip(corners, corners[1:]):
add_line((sx, sy, z0), (ex, ey, z0))
add_line((sx, sy, z1), (ex, ey, z1))
for x, y in corners[:-1]:
add_line((x, y, z0), (x, y, z1))
text = layout.add_text(box.name, height=0.25, dxfattribs=attribs)
text.set_placement((x0 + 0.25, y1 - 0.5, z1))
text.transform(m)
def _add_mesh(
layout: "GenericLayoutType", item: Item, layer: str, color: int, m: Matrix44
):
from ezdxf.render.forms import cube
attribs = {
"layer": layer,
"color": color,
}
mesh = cube(center=False)
sx, sy, sz = item.get_dimension()
mesh.scale(sx, sy, sz)
x, y, z = item.position
mesh.translate(x, y, z)
mesh.render_polyface(layout, attribs, matrix=m)
text = layout.add_text(
str(item.payload), height=0.25, dxfattribs={"layer": "TEXT"}
)
if sy > sx:
text.dxf.rotation = 90
align = TextEntityAlignment.TOP_LEFT
else:
align = TextEntityAlignment.BOTTOM_LEFT
text.set_placement((x + 0.25, y + 0.25, z + sz), align=align)
text.transform(m)