Passed
Push — 3.x ( 8ba8a4...c7e27c )
by Doug
01:45
created

LayerPacker   A

Complexity

Total Complexity 30

Size/Duplication

Total Lines 206
Duplicated Lines 0 %

Test Coverage

Coverage 99.03%

Importance

Changes 8
Bugs 0 Features 0
Metric Value
eloc 96
c 8
b 0
f 0
dl 0
loc 206
ccs 102
cts 103
cp 0.9903
rs 10
wmc 30

7 Methods

Rating   Name   Duplication   Size   Complexity  
A isSameDimensions() 0 11 2
A __construct() 0 7 1
A setLogger() 0 4 1
A setSinglePassMode() 0 4 1
A checkNonDimensionalConstraints() 0 8 4
C packLayer() 0 82 12
B packVerticallyInsideItemFootprint() 0 33 9
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 array_merge;
12
use function iterator_to_array;
13
use function max;
14
use Psr\Log\LoggerAwareInterface;
15
use Psr\Log\LoggerInterface;
16
use Psr\Log\NullLogger;
17
use function sort;
18
19
/**
20
 * Layer packer.
21
 *
22
 * @internal
23
 * @author Doug Wright
24
 */
25
class LayerPacker implements LoggerAwareInterface
26
{
27
    /**
28
     * The logger instance.
29
     *
30
     * @var LoggerInterface
31
     */
32
    private $logger;
33
34
    /**
35
     * Box to pack items into.
36
     *
37
     * @var Box
38
     */
39
    private $box;
40
41
    /**
42
     * Whether the packer is in single-pass mode.
43
     *
44
     * @var bool
45
     */
46
    private $singlePassMode = false;
47
48
    /**
49
     * @var OrientatedItemFactory
50
     */
51
    private $orientatedItemFactory;
52
53
    /**
54
     * Constructor.
55
     */
56 55
    public function __construct(Box $box)
57
    {
58 55
        $this->box = $box;
59 55
        $this->logger = new NullLogger();
60
61 55
        $this->orientatedItemFactory = new OrientatedItemFactory($this->box);
62 55
        $this->orientatedItemFactory->setLogger($this->logger);
63 55
    }
64
65
    /**
66
     * Sets a logger.
67
     */
68 55
    public function setLogger(LoggerInterface $logger): void
69
    {
70 55
        $this->logger = $logger;
71 55
        $this->orientatedItemFactory->setLogger($logger);
72 55
    }
73
74 36
    public function setSinglePassMode(bool $singlePassMode): void
75
    {
76 36
        $this->singlePassMode = $singlePassMode;
77 36
        $this->orientatedItemFactory->setSinglePassMode($singlePassMode);
78 36
    }
79
80
    /**
81
     * Pack items into an individual vertical layer.
82
     */
83 55
    public function packLayer(ItemList &$items, PackedItemList $packedItemList, int $startX, int $startY, int $startZ, int $widthForLayer, int $lengthForLayer, int $depthForLayer, int $guidelineLayerDepth, bool $considerStability): PackedLayer
84
    {
85 55
        $layer = new PackedLayer();
86 55
        $x = $startX;
87 55
        $y = $startY;
88 55
        $z = $startZ;
89 55
        $lengthLeft = $lengthForLayer;
90 55
        $rowLength = 0;
91 55
        $prevItem = null;
92 55
        $skippedItems = [];
93 55
        $remainingWeightAllowed = $this->box->getMaxWeight() - $this->box->getEmptyWeight() - $packedItemList->getWeight();
94
95 55
        while ($items->count() > 0) {
96 55
            $itemToPack = $items->extract();
97
98
            //skip items that will never fit e.g. too heavy
99 55
            if (!$this->checkNonDimensionalConstraints($itemToPack, $remainingWeightAllowed, $packedItemList)) {
100 7
                continue;
101
            }
102
103 55
            $orientatedItem = $this->orientatedItemFactory->getBestOrientation($itemToPack, $prevItem, $items, $widthForLayer - $x, $lengthLeft, $depthForLayer, $rowLength, $x, $y, $z, $packedItemList, $considerStability);
104
105 55
            if ($orientatedItem instanceof OrientatedItem) {
106 55
                $packedItem = PackedItem::fromOrientatedItem($orientatedItem, $x, $y, $z);
107 55
                $layer->insert($packedItem);
108 55
                $remainingWeightAllowed -= $itemToPack->getWeight();
109 55
                $packedItemList->insert($packedItem);
110
111 55
                $rowLength = max($rowLength, $packedItem->getLength());
112
113
                //Figure out if we can stack the next item vertically on top of this rather than side by side
114
                //e.g. when we've packed a tall item, and have just put a shorter one next to it.
115 55
                $this->packVerticallyInsideItemFootprint($layer, $packedItem, $packedItemList, $items, $remainingWeightAllowed, $guidelineLayerDepth, $rowLength, $x, $y, $z, $considerStability);
116
117 55
                $prevItem = $orientatedItem;
118
119
                //Having now placed an item, there is space *within the same row* along the length. Pack into that.
120 55
                if (!$this->singlePassMode && $rowLength - $orientatedItem->getLength() > 0) {
121 9
                    $layer->merge($this->packLayer($items, $packedItemList, $x, $y + $orientatedItem->getLength(), $z, $widthForLayer, $rowLength - $orientatedItem->getLength(), $depthForLayer, $layer->getDepth(), $considerStability));
122
                }
123
124 55
                $x += $packedItem->getWidth();
125
126 55
                if ($items->count() === 0 && $skippedItems) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $skippedItems of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
127 6
                    $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
128 6
                    $skippedItems = [];
129
                }
130 55
                continue;
131
            }
132
133 48
            if ($items->count() > 0) { // skip for now, move on to the next item
134 40
                $this->logger->debug("doesn't fit, skipping for now");
135 40
                $skippedItems[] = $itemToPack;
136
                // 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
137 40
                while ($items->count() > 1 && static::isSameDimensions($itemToPack, $items->top())) {
138 22
                    $skippedItems[] = $items->extract();
139
                }
140 40
                continue;
141
            }
142
143 48
            if ($x > $startX) {
144 48
                $this->logger->debug('No more fit in width wise, resetting for new row');
145 48
                $lengthLeft -= $rowLength;
146 48
                $y += $rowLength;
147 48
                $x = $startX;
148 48
                $rowLength = 0;
149 48
                $skippedItems[] = $itemToPack;
150 48
                $items = ItemList::fromArray($skippedItems, true);
151 48
                $skippedItems = [];
152 48
                $prevItem = null;
153 48
                continue;
154
            }
155
156 40
            $this->logger->debug('no items fit, so starting next vertical layer');
157 40
            $skippedItems[] = $itemToPack;
158
159 40
            $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
160
161 40
            return $layer;
162
        }
163
164 54
        return $layer;
165
    }
166
167 55
    private function packVerticallyInsideItemFootprint(PackedLayer $layer, PackedItem $packedItem, PackedItemList $packedItemList, ItemList &$items, int &$remainingWeightAllowed, int $guidelineLayerDepth, int $rowLength, int $x, int $y, int $z, bool $considerStability): void
168
    {
169 55
        $stackableDepth = ($guidelineLayerDepth ?: $layer->getDepth()) - $packedItem->getDepth();
170 55
        $stackedZ = $z + $packedItem->getDepth();
171 55
        $stackSkippedItems = [];
172 55
        $stackedItem = $packedItem->toOrientatedItem();
173 55
        while ($stackableDepth > 0 && $items->count() > 0) {
174 17
            $itemToTryStacking = $items->extract();
175
176
            //skip items that will never fit
177 17
            if (!$this->checkNonDimensionalConstraints($itemToTryStacking, $remainingWeightAllowed, $packedItemList)) {
178
                continue;
179
            }
180
181 17
            $stackedItem = $this->orientatedItemFactory->getBestOrientation($itemToTryStacking, $stackedItem, $items, $packedItem->getWidth(), $packedItem->getLength(), $stackableDepth, $rowLength, $x, $y, $stackedZ, $packedItemList, $considerStability);
182 17
            if ($stackedItem) {
183 11
                $packedStackedItem = PackedItem::fromOrientatedItem($stackedItem, $x, $y, $stackedZ);
184 11
                $layer->insert($packedStackedItem);
185 11
                $remainingWeightAllowed -= $itemToTryStacking->getWeight();
186 11
                $packedItemList->insert($packedStackedItem);
187 11
                $stackableDepth -= $stackedItem->getDepth();
188 11
                $stackedZ += $stackedItem->getDepth();
189 11
                continue;
190
            }
191
192 13
            $stackSkippedItems[] = $itemToTryStacking;
193
            // abandon here if next item is the same, no point trying to keep going
194 13
            while ($items->count() > 0 && static::isSameDimensions($itemToTryStacking, $items->top())) {
195 8
                $stackSkippedItems[] = $items->extract();
196
            }
197
        }
198 55
        if ($stackSkippedItems) {
199 13
            $items = ItemList::fromArray(array_merge($stackSkippedItems, iterator_to_array($items)), true);
200
        }
201 55
    }
202
203
    /**
204
     * As well as purely dimensional constraints, there are other constraints that need to be met
205
     * e.g. weight limits or item-specific restrictions (e.g. max <x> batteries per box).
206
     */
207 55
    private function checkNonDimensionalConstraints(Item $itemToPack, int $remainingWeightAllowed, PackedItemList $packedItemList): bool
208
    {
209 55
        $customConstraintsOK = true;
210 55
        if ($itemToPack instanceof ConstrainedItem && !$this->box instanceof WorkingVolume) {
211 1
            $customConstraintsOK = $itemToPack->canBePackedInBox($packedItemList, $this->box);
212
        }
213
214 55
        return $customConstraintsOK && $itemToPack->getWeight() <= $remainingWeightAllowed;
215
    }
216
217
    /**
218
     * Compare two items to see if they have same dimensions.
219
     */
220 29
    private static function isSameDimensions(Item $itemA, Item $itemB): bool
221
    {
222 29
        if ($itemA === $itemB) {
223 20
            return true;
224
        }
225 13
        $itemADimensions = [$itemA->getWidth(), $itemA->getLength(), $itemA->getDepth()];
226 13
        $itemBDimensions = [$itemB->getWidth(), $itemB->getLength(), $itemB->getDepth()];
227 13
        sort($itemADimensions);
228 13
        sort($itemBDimensions);
229
230 13
        return $itemADimensions === $itemBDimensions;
231
    }
232
}
233