Skip to content

Commit

Permalink
Backport packing improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
dvdoug committed Jul 26, 2023
1 parent dab2820 commit d739e8a
Show file tree
Hide file tree
Showing 13 changed files with 764 additions and 437 deletions.
10 changes: 5 additions & 5 deletions src/LayerPacker.php
Expand Up @@ -106,28 +106,28 @@ public function packLayer(ItemList &$items, PackedItemList $packedItemList, int
$x += $packedItem->getWidth();
$remainingWeightAllowed = $this->box->getMaxWeight() - $this->box->getEmptyWeight() - $packedItemList->getWeight(); // remember may have packed additional items

// might be space available lengthwise across the width of this item, up to the current layer length
$layer->merge($this->packLayer($items, $packedItemList, $x - $packedItem->getWidth(), $y + $packedItem->getLength(), $z, $x, $y + $rowLength, $depthForLayer, $layer->getDepth(), $considerStability));

if ($items->count() === 0 && $skippedItems) {
$items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
$skippedItems = [];
}

continue;
}

if (!$this->beStrictAboutItemOrdering && $items->count() > 0) { // skip for now, move on to the next item
$this->logger->debug("doesn't fit, skipping for now");
$skippedItems[] = $itemToPack;
// abandon here if next item is the same, no point trying to keep going. Last time is not skipped, need that to trigger appropriate reset logic
while ($items->count() > 1 && static::isSameDimensions($itemToPack, $items->top())) {
while ($items->count() > 1 && self::isSameDimensions($itemToPack, $items->top())) {
$skippedItems[] = $items->extract();
}
continue;
}

if ($x > $startX) {
// Having now placed items, there is space *within the same row* along the length. Pack into that.
$this->logger->debug('No more fit in width wise, packing along remaining length');
$layer->merge($this->packLayer($items, $packedItemList, $x, $y + $rowLength, $z, $widthForLayer, $lengthForLayer - $rowLength, $depthForLayer, $layer->getDepth(), $considerStability));

$this->logger->debug('No more fit in width wise, resetting for new row');
$y += $rowLength;
$x = $startX;
Expand Down
2 changes: 1 addition & 1 deletion src/OrientatedItem.php
Expand Up @@ -31,7 +31,7 @@ class OrientatedItem implements JsonSerializable
protected int $surfaceFootprint;

/**
* @var bool[]
* @var array<string, bool>
*/
protected static array $stabilityCache = [];

Expand Down
24 changes: 18 additions & 6 deletions src/OrientatedItemFactory.php
Expand Up @@ -9,7 +9,7 @@
namespace DVDoug\BoxPacker;

use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

use function array_filter;
Expand All @@ -23,7 +23,7 @@
*/
class OrientatedItemFactory implements LoggerAwareInterface
{
use LoggerAwareTrait;
protected LoggerInterface $logger;

protected Box $box;

Expand All @@ -33,9 +33,9 @@ class OrientatedItemFactory implements LoggerAwareInterface
protected bool $singlePassMode = false;

/**
* @var bool[]
* @var array<string, bool>
*/
protected static $emptyBoxStableItemOrientationCache = [];
protected static array $emptyBoxStableItemOrientationCache = [];

public function __construct(Box $box)
{
Expand All @@ -48,6 +48,11 @@ public function setSinglePassMode(bool $singlePassMode): void
$this->singlePassMode = $singlePassMode;
}

public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}

/**
* Get the best orientation for an item.
*/
Expand All @@ -74,6 +79,11 @@ public function getBestOrientation(
'lengthLeft' => $lengthLeft,
'depthLeft' => $depthLeft,
],
'position' => [
'x' => $x,
'y' => $y,
'z' => $z,
],
]
);

Expand All @@ -84,8 +94,7 @@ public function getBestOrientation(
return null;
}

$sorter = new OrientatedItemSorter($this, $this->singlePassMode, $widthLeft, $lengthLeft, $depthLeft, $nextItems, $rowLength, $x, $y, $z, $prevPackedItemList);
$sorter->setLogger($this->logger);
$sorter = new OrientatedItemSorter($this, $this->singlePassMode, $widthLeft, $lengthLeft, $depthLeft, $nextItems, $rowLength, $x, $y, $z, $prevPackedItemList, $this->logger);
usort($usableOrientations, $sorter);

$this->logger->debug('Selected best fit orientation', ['orientation' => $usableOrientations[0]]);
Expand Down Expand Up @@ -206,6 +215,9 @@ protected function hasStableOrientationsInEmptyBox(Item $item): bool
return static::$emptyBoxStableItemOrientationCache[$cacheKey];
}

/**
* @return array<array<int>>
*/
private function generatePermutations(Item $item, ?OrientatedItem $prevItem): array
{
// Special case items that are the same as what we just packed - keep orientation
Expand Down
14 changes: 7 additions & 7 deletions src/OrientatedItemSorter.php
Expand Up @@ -8,8 +8,7 @@

namespace DVDoug\BoxPacker;

use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\LoggerInterface;

use function max;
use function min;
Expand All @@ -21,10 +20,8 @@
*
* @internal
*/
class OrientatedItemSorter implements LoggerAwareInterface
class OrientatedItemSorter
{
use LoggerAwareTrait;

/**
* @var int[]
*/
Expand Down Expand Up @@ -52,7 +49,9 @@ class OrientatedItemSorter implements LoggerAwareInterface

private PackedItemList $prevPackedItemList;

public function __construct(OrientatedItemFactory $factory, bool $singlePassMode, int $widthLeft, int $lengthLeft, int $depthLeft, ItemList $nextItems, int $rowLength, int $x, int $y, int $z, PackedItemList $prevPackedItemList)
private LoggerInterface $logger;

public function __construct(OrientatedItemFactory $factory, bool $singlePassMode, int $widthLeft, int $lengthLeft, int $depthLeft, ItemList $nextItems, int $rowLength, int $x, int $y, int $z, PackedItemList $prevPackedItemList, LoggerInterface $logger)
{
$this->orientatedItemFactory = $factory;
$this->singlePassMode = $singlePassMode;
Expand All @@ -65,9 +64,10 @@ public function __construct(OrientatedItemFactory $factory, bool $singlePassMode
$this->y = $y;
$this->z = $z;
$this->prevPackedItemList = $prevPackedItemList;
$this->logger = $logger;
}

public function __invoke(OrientatedItem $a, OrientatedItem $b)
public function __invoke(OrientatedItem $a, OrientatedItem $b): int
{
// Prefer exact fits in width/length/depth order
$orientationAWidthLeft = $this->widthLeft - $a->getWidth();
Expand Down
21 changes: 13 additions & 8 deletions src/Packer.php
Expand Up @@ -9,7 +9,7 @@
namespace DVDoug\BoxPacker;

use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Psr\Log\NullLogger;
use SplObjectStorage;
Expand All @@ -25,7 +25,7 @@
*/
class Packer implements LoggerAwareInterface
{
use LoggerAwareTrait;
private LoggerInterface $logger;

/**
* Number of boxes at which balancing weight is deemed not worth it.
Expand All @@ -39,7 +39,7 @@ class Packer implements LoggerAwareInterface
/**
* @var SplObjectStorage<Box, int>
*/
protected SplObjectStorage $boxesQtyAvailable;
protected SplObjectStorage $boxQuantitiesAvailable;

protected PackedBoxSorter $packedBoxSorter;

Expand All @@ -49,12 +49,17 @@ public function __construct()
{
$this->items = new ItemList();
$this->boxes = new BoxList();
$this->boxesQtyAvailable = new SplObjectStorage();
$this->boxQuantitiesAvailable = new SplObjectStorage();
$this->packedBoxSorter = new DefaultPackedBoxSorter();

$this->logger = new NullLogger();
}

public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}

/**
* Add item to be packed.
*/
Expand Down Expand Up @@ -106,7 +111,7 @@ public function setBoxes(BoxList $boxList): void
*/
public function setBoxQuantity(Box $box, int $qty): void
{
$this->boxesQtyAvailable[$box] = $qty;
$this->boxQuantitiesAvailable[$box] = $qty;
}

/**
Expand Down Expand Up @@ -144,7 +149,7 @@ public function pack(): PackedBoxList

// If we have multiple boxes, try and optimise/even-out weight distribution
if (!$this->beStrictAboutItemOrdering && $packedBoxes->count() > 1 && $packedBoxes->count() <= $this->maxBoxesToBalanceWeight) {
$redistributor = new WeightRedistributor($this->boxes, $this->packedBoxSorter, $this->boxesQtyAvailable);
$redistributor = new WeightRedistributor($this->boxes, $this->packedBoxSorter, $this->boxQuantitiesAvailable);
$redistributor->setLogger($this->logger);
$packedBoxes = $redistributor->redistributeWeight($packedBoxes);
}
Expand Down Expand Up @@ -197,7 +202,7 @@ public function doVolumePacking(bool $singlePassMode = false, bool $enforceSingl
$this->items->removePackedItems($bestBox->getItems());

$packedBoxes->insert($bestBox);
$this->boxesQtyAvailable[$bestBox->getBox()] = $this->boxesQtyAvailable[$bestBox->getBox()] - 1;
$this->boxQuantitiesAvailable[$bestBox->getBox()] = $this->boxQuantitiesAvailable[$bestBox->getBox()] - 1;
}

return $packedBoxes;
Expand All @@ -218,7 +223,7 @@ protected function getBoxList(bool $enforceSingleBox = false): iterable
$preferredBoxes = [];
$otherBoxes = [];
foreach ($this->boxes as $box) {
if ($this->boxesQtyAvailable[$box] > 0) {
if ($this->boxQuantitiesAvailable[$box] > 0) {
if ($box->getInnerWidth() * $box->getInnerLength() * $box->getInnerDepth() >= $itemVolume) {
$preferredBoxes[] = $box;
} elseif (!$enforceSingleBox) {
Expand Down
53 changes: 27 additions & 26 deletions src/WeightRedistributor.php
Expand Up @@ -9,7 +9,7 @@
namespace DVDoug\BoxPacker;

use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Psr\Log\NullLogger;
use SplObjectStorage;
Expand All @@ -29,30 +29,30 @@
*/
class WeightRedistributor implements LoggerAwareInterface
{
use LoggerAwareTrait;
private LoggerInterface $logger;

/**
* List of box sizes available to pack items into.
*/
private BoxList $boxes;

/**
* Quantities available of each box type.
*
* @var SplObjectStorage|int[]
* @var SplObjectStorage<Box, int>
*/
private $boxesQtyAvailable;
private SplObjectStorage $boxQuantitiesAvailable;

private PackedBoxSorter $packedBoxSorter;

public function __construct(BoxList $boxList, PackedBoxSorter $packedBoxSorter, SplObjectStorage $boxQuantitiesAvailable)
{
$this->boxes = $boxList;
$this->packedBoxSorter = $packedBoxSorter;
$this->boxesQtyAvailable = $boxQuantitiesAvailable;
$this->boxQuantitiesAvailable = $boxQuantitiesAvailable;
$this->logger = new NullLogger();
}

public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}

/**
* Given a solution set of packed boxes, repack them to achieve optimum weight distribution.
*/
Expand All @@ -77,17 +77,15 @@ public function redistributeWeight(PackedBoxList $originalBoxes): PackedBoxList

$iterationSuccessful = $this->equaliseWeight($boxA, $boxB, $targetWeight);
if ($iterationSuccessful) {
$boxes = array_filter($boxes, static function (?PackedBox $box) { // remove any now-empty boxes from the list
return $box instanceof PackedBox;
});
$boxes = array_filter($boxes, static fn (?PackedBox $box) => $box instanceof PackedBox); // remove any now-empty boxes from the list
break 2;
}
}
}
} while ($iterationSuccessful);

// Combine back into a single list
$packedBoxes = new PackedBoxList();
$packedBoxes = new PackedBoxList($this->packedBoxSorter);
$packedBoxes->insertFromArray($boxes);

return $packedBoxes;
Expand All @@ -114,7 +112,7 @@ private function equaliseWeight(PackedBox &$boxA, PackedBox &$boxB, float $targe
$underWeightBoxItems = $underWeightBox->getItems()->asItemArray();

foreach ($overWeightBoxItems as $key => $overWeightItem) {
if (!static::wouldRepackActuallyHelp($overWeightBoxItems, $overWeightItem, $underWeightBoxItems, $targetWeight)) {
if (!self::wouldRepackActuallyHelp($overWeightBoxItems, $overWeightItem, $underWeightBoxItems, $targetWeight)) {
continue; // moving this item would harm more than help
}

Expand All @@ -128,8 +126,8 @@ private function equaliseWeight(PackedBox &$boxA, PackedBox &$boxB, float $targe
if (count($overWeightBoxItems) === 1) { // sometimes a repack can be efficient enough to eliminate a box
$boxB = $newLighterBoxes->top();
$boxA = null;
$this->boxesQtyAvailable[$underWeightBox->getBox()] = $this->boxesQtyAvailable[$underWeightBox->getBox()] - 1;
$this->boxesQtyAvailable[$overWeightBox->getBox()] = $this->boxesQtyAvailable[$overWeightBox->getBox()] + 1;
$this->boxQuantitiesAvailable[$underWeightBox->getBox()] = $this->boxQuantitiesAvailable[$underWeightBox->getBox()] - 1;
$this->boxQuantitiesAvailable[$overWeightBox->getBox()] = $this->boxQuantitiesAvailable[$overWeightBox->getBox()] + 1;

return true;
}
Expand All @@ -140,10 +138,10 @@ private function equaliseWeight(PackedBox &$boxA, PackedBox &$boxB, float $targe
continue; // this should never happen, if we can pack n+1 into the box, we should be able to pack n
}

$this->boxesQtyAvailable[$overWeightBox->getBox()] = $this->boxesQtyAvailable[$overWeightBox->getBox()] + 1;
$this->boxesQtyAvailable[$underWeightBox->getBox()] = $this->boxesQtyAvailable[$underWeightBox->getBox()] + 1;
$this->boxesQtyAvailable[$newHeavierBoxes->top()->getBox()] = $this->boxesQtyAvailable[$newHeavierBoxes->top()->getBox()] - 1;
$this->boxesQtyAvailable[$newLighterBoxes->top()->getBox()] = $this->boxesQtyAvailable[$newLighterBoxes->top()->getBox()] - 1;
$this->boxQuantitiesAvailable[$overWeightBox->getBox()] = $this->boxQuantitiesAvailable[$overWeightBox->getBox()] + 1;
$this->boxQuantitiesAvailable[$underWeightBox->getBox()] = $this->boxQuantitiesAvailable[$underWeightBox->getBox()] + 1;
$this->boxQuantitiesAvailable[$newHeavierBoxes->top()->getBox()] = $this->boxQuantitiesAvailable[$newHeavierBoxes->top()->getBox()] - 1;
$this->boxQuantitiesAvailable[$newLighterBoxes->top()->getBox()] = $this->boxQuantitiesAvailable[$newLighterBoxes->top()->getBox()] - 1;
$underWeightBox = $boxB = $newLighterBoxes->top();
$overWeightBox = $boxA = $newHeavierBoxes->top();

Expand All @@ -155,16 +153,17 @@ private function equaliseWeight(PackedBox &$boxA, PackedBox &$boxB, float $targe

/**
* Do a volume repack of a set of items.
* @param iterable<Item> $items
*/
private function doVolumeRepack(iterable $items, Box $currentBox): PackedBoxList
{
$packer = new Packer();
$packer->setLogger($this->logger);
$packer->setBoxes($this->boxes); // use the full set of boxes to allow smaller/larger for full efficiency
foreach ($this->boxes as $box) {
$packer->setBoxQuantity($box, $this->boxesQtyAvailable[$box]);
$packer->setBoxQuantity($box, $this->boxQuantitiesAvailable[$box]);
}
$packer->setBoxQuantity($currentBox, $this->boxesQtyAvailable[$currentBox] + 1);
$packer->setBoxQuantity($currentBox, $this->boxQuantitiesAvailable[$currentBox] + 1);
$packer->setItems($items);

return $packer->doVolumePacking(true, true);
Expand All @@ -174,6 +173,8 @@ private function doVolumeRepack(iterable $items, Box $currentBox): PackedBoxList
* Not every attempted repack is actually helpful - sometimes moving an item between two otherwise identical
* boxes, or sometimes the box used for the now lighter set of items actually weighs more when empty causing
* an increase in total weight.
* @param array<Item> $overWeightBoxItems
* @param array<Item> $underWeightBoxItems
*/
private static function wouldRepackActuallyHelp(array $overWeightBoxItems, Item $overWeightItem, array $underWeightBoxItems, float $targetWeight): bool
{
Expand All @@ -184,13 +185,13 @@ private static function wouldRepackActuallyHelp(array $overWeightBoxItems, Item
return false;
}

$oldVariance = static::calculateVariance($overWeightItemsWeight, $underWeightItemsWeight);
$newVariance = static::calculateVariance($overWeightItemsWeight - $overWeightItem->getWeight(), $underWeightItemsWeight + $overWeightItem->getWeight());
$oldVariance = self::calculateVariance($overWeightItemsWeight, $underWeightItemsWeight);
$newVariance = self::calculateVariance($overWeightItemsWeight - $overWeightItem->getWeight(), $underWeightItemsWeight + $overWeightItem->getWeight());

return $newVariance < $oldVariance;
}

private static function calculateVariance(int $boxAWeight, int $boxBWeight)
private static function calculateVariance(int $boxAWeight, int $boxBWeight): float
{
return ($boxAWeight - (($boxAWeight + $boxBWeight) / 2)) ** 2; // don't need to calculate B and ÷ 2, for a 2-item population the difference from mean is the same for each box
}
Expand Down

0 comments on commit d739e8a

Please sign in to comment.