Passed
Push — master ( cb2e0a...ce89c2 )
by Doug
05:31
created

LayerPacker::isSameDimensions()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 7
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 11
ccs 8
cts 8
cp 1
crap 2
rs 10
1
<?php
2
/**
3
 * Box packing (3D bin packing, knapsack problem).
4
 *
5
 * @author Doug Wright
6
 */
7
declare(strict_types=1);
8
9
namespace DVDoug\BoxPacker;
10
11
use function count;
12
use function max;
13
use Psr\Log\LoggerAwareInterface;
14
use Psr\Log\LoggerInterface;
15
use Psr\Log\NullLogger;
16
17
/**
18
 * Layer packer.
19
 *
20
 * @internal
21
 * @author Doug Wright
22
 */
23
class LayerPacker implements LoggerAwareInterface
24
{
25
    /**
26
     * The logger instance.
27
     *
28
     * @var LoggerInterface
29
     */
30
    private $logger;
31
32
    /**
33
     * Box to pack items into.
34
     *
35
     * @var Box
36
     */
37
    private $box;
38
39
    /**
40
     * Whether the packer is in single-pass mode.
41
     *
42
     * @var bool
43
     */
44
    private $singlePassMode = false;
45
46
    /**
47
     * @var OrientatedItemFactory
48
     */
49
    private $orientatedItemFactory;
50
51
    /**
52
     * Constructor.
53
     */
54 56
    public function __construct(Box $box)
55
    {
56 56
        $this->box = $box;
57 56
        $this->logger = new NullLogger();
58
59 56
        $this->orientatedItemFactory = new OrientatedItemFactory($this->box);
60 56
        $this->orientatedItemFactory->setLogger($this->logger);
61 56
    }
62
63
    /**
64
     * Sets a logger.
65
     */
66 56
    public function setLogger(LoggerInterface $logger): void
67
    {
68 56
        $this->logger = $logger;
69 56
        $this->orientatedItemFactory->setLogger($logger);
70 56
    }
71
72 51
    public function setSinglePassMode(bool $singlePassMode): void
73
    {
74 51
        $this->singlePassMode = $singlePassMode;
75 51
        $this->orientatedItemFactory->setSinglePassMode($singlePassMode);
76 51
    }
77
78
    /**
79
     * Pack items into an individual vertical layer.
80
     */
81 56
    public function packLayer(ItemList &$items, PackedItemList $packedItemList, array $layers, int $z, int $layerWidth, int $lengthLeft, int $depthLeft, int $guidelineLayerDepth): PackedLayer
82
    {
83 56
        $layer = new PackedLayer();
84 56
        $prevItem = null;
85 56
        $x = $y = $rowLength = 0;
86 56
        $skippedItems = [];
87 56
        $remainingWeightAllowed = $this->getRemainingWeightAllowed($layers);
88
89 56
        while ($items->count() > 0) {
90 56
            $itemToPack = $items->extract();
91
92
            //skip items that are simply too heavy or too large
93 56
            if (!$this->checkNonDimensionalConstraints($itemToPack, $remainingWeightAllowed, $packedItemList)) {
94 7
                continue;
95
            }
96
97 56
            $orientatedItem = $this->orientatedItemFactory->getBestOrientation($itemToPack, $prevItem instanceof PackedItem ? $prevItem->toOrientatedItem() : null, $items, $layerWidth - $x, $lengthLeft, $depthLeft, $rowLength, $x, $y, $z, $packedItemList);
98
99 56
            if ($orientatedItem instanceof OrientatedItem) {
100 56
                $packedItem = PackedItem::fromOrientatedItem($orientatedItem, $x, $y, $z);
101 56
                $layer->insert($packedItem);
102 56
                $remainingWeightAllowed -= $itemToPack->getWeight();
103 56
                $packedItemList->insert($packedItem);
104
105 56
                $rowLength = max($rowLength, $packedItem->getLength());
106
107
                //Figure out if we can stack the next item vertically on top of this rather than side by side
108
                //e.g. when we've packed a tall item, and have just put a shorter one next to it.
109 56
                $this->packVerticallyInsideItemFootprint($layer, $packedItem, $packedItemList, $items, $remainingWeightAllowed, $guidelineLayerDepth, $rowLength, $x, $y, $z);
110 56
                $x += $packedItem->getWidth();
111
112 56
                $prevItem = $packedItem;
113 56
                if ($items->count() === 0) {
114 53
                    $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
115 56
                    $skippedItems = [];
116
                }
117 50
            } elseif (count($layer->getItems()) === 0) { // zero items on layer
118 39
                $this->logger->debug("doesn't fit on layer even when empty, skipping for good");
119 39
                continue;
120 49
            } elseif ($items->count() > 0) { // skip for now, move on to the next item
121 41
                $this->logger->debug("doesn't fit, skipping for now");
122 41
                $skippedItems[] = $itemToPack;
123
                // 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
124 41
                while ($items->count() > 2 && static::isSameDimensions($itemToPack, $items->top())) {
125 22
                    $skippedItems[] = $items->extract();
126
                }
127 49
            } elseif ($x > 0) {
128 49
                $this->logger->debug('No more fit in width wise, resetting for new row');
129 49
                $lengthLeft -= $rowLength;
130 49
                $y += $rowLength;
131 49
                $x = $rowLength = 0;
132 49
                $skippedItems[] = $itemToPack;
133 49
                $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
134 49
                $skippedItems = [];
135 49
                $prevItem = null;
136 49
                continue;
137
            } else {
138 44
                $this->logger->debug('no items fit, so starting next vertical layer');
139 44
                $skippedItems[] = $itemToPack;
140
141 44
                $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
142
143 44
                return $layer;
144
            }
145
        }
146
147 56
        return $layer;
148
    }
149
150 56
    private function packVerticallyInsideItemFootprint(PackedLayer $layer, PackedItem $packedItem, PackedItemList $packedItemList, ItemList &$items, int &$remainingWeightAllowed, int $guidelineLayerDepth, int $rowLength, int $x, int $y, int $z): void
151
    {
152 56
        $stackableDepth = ($guidelineLayerDepth ?: $layer->getDepth()) - $packedItem->getDepth();
153 56
        $stackedZ = $z + $packedItem->getDepth();
154 56
        $stackSkippedItems = [];
155 56
        while ($stackableDepth > 0 && $items->count() > 0) {
156 17
            $itemToTryStacking = $items->extract();
157 17
            $stackedItem = $this->orientatedItemFactory->getBestOrientation($itemToTryStacking, $packedItem->toOrientatedItem(), $items, $packedItem->getWidth(), $packedItem->getLength(), $stackableDepth, $rowLength, $x, $y, $stackedZ, $packedItemList);
158 17
            if ($stackedItem && $this->checkNonDimensionalConstraints($itemToTryStacking, $remainingWeightAllowed, $packedItemList)) {
159 11
                $layer->insert(PackedItem::fromOrientatedItem($stackedItem, $x, $y, $stackedZ));
160 11
                $remainingWeightAllowed -= $itemToTryStacking->getWeight();
161 11
                $packedItemList->insert($packedItem);
162 11
                $stackableDepth -= $stackedItem->getDepth();
163 11
                $stackedZ += $stackedItem->getDepth();
164
            } else {
165 13
                $stackSkippedItems[] = $itemToTryStacking;
166
                // 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
167 13
                while ($items->count() > 0 && static::isSameDimensions($itemToTryStacking, $items->top())) {
168 10
                    $stackSkippedItems[] = $items->extract();
169
                }
170
            }
171
        }
172 56
        if ($stackSkippedItems) {
173 13
            $items = ItemList::fromArray(array_merge($stackSkippedItems, iterator_to_array($items)), true);
174
        }
175 56
    }
176
177
    /**
178
     * As well as purely dimensional constraints, there are other constraints that need to be met
179
     * e.g. weight limits or item-specific restrictions (e.g. max <x> batteries per box).
180
     */
181 56
    private function checkNonDimensionalConstraints(Item $itemToPack, int $remainingWeightAllowed, PackedItemList $packedItemList): bool
182
    {
183 56
        $customConstraintsOK = true;
184 56
        if ($itemToPack instanceof ConstrainedItem) {
185 1
            $customConstraintsOK = $itemToPack->canBePackedInBox($packedItemList, $this->box);
186
        }
187
188 56
        return $customConstraintsOK && $itemToPack->getWeight() <= $remainingWeightAllowed;
189
    }
190
191 56
    private function getRemainingWeightAllowed(array $layers): int
192
    {
193 56
        $remainingWeightAllowed = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
194 56
        foreach ($layers as $layer) {
195 44
            $remainingWeightAllowed -= $layer->getWeight();
196
        }
197
198 56
        return $remainingWeightAllowed;
199
    }
200
201
    /**
202
     * Compare two items to see if they have same dimensions.
203
     */
204 26
    private static function isSameDimensions(Item $itemA, Item $itemB): bool
205
    {
206 26
        if ($itemA === $itemB) {
207 19
            return true;
208
        }
209 10
        $itemADimensions = [$itemA->getWidth(), $itemA->getLength(), $itemA->getDepth()];
210 10
        $itemBDimensions = [$itemB->getWidth(), $itemB->getLength(), $itemB->getDepth()];
211 10
        sort($itemADimensions);
212 10
        sort($itemBDimensions);
213
214 10
        return $itemADimensions === $itemBDimensions;
215
    }
216
}
217