Completed
Push — issue_187 ( 8952f9...e99851 )
by Doug
12:21
created

VolumePacker   B

Complexity

Total Complexity 44

Size/Duplication

Total Lines 381
Duplicated Lines 0 %

Test Coverage

Coverage 99.34%

Importance

Changes 26
Bugs 2 Features 0
Metric Value
eloc 143
c 26
b 2
f 0
dl 0
loc 381
ccs 151
cts 152
cp 0.9934
rs 8.8798
wmc 44

15 Methods

Rating   Name   Duplication   Size   Complexity  
A setLookAheadMode() 0 3 1
A getPackedItemList() 0 10 3
A __construct() 0 17 3
A rotateLayersNinetyDegrees() 0 9 3
A getOrientationForItem() 0 31 3
A stabiliseLayers() 0 5 1
A getCurrentPackedDepth() 0 8 2
A pack() 0 20 4
C packLayer() 0 61 12
A setLogger() 0 4 1
A tryAndStackItemsIntoSpace() 0 34 4
A rebuildItemList() 0 4 1
A hasItemsLeftToPack() 0 3 1
A isSameDimensions() 0 11 2
A checkNonDimensionalConstraints() 0 8 3

How to fix   Complexity   

Complex Class

Complex classes like VolumePacker often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use VolumePacker, and based on these observations, apply Extract Interface, too.

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 function min;
14
use Psr\Log\LoggerAwareInterface;
15
use Psr\Log\LoggerInterface;
16
use Psr\Log\NullLogger;
17
18
/**
19
 * Actual packer.
20
 *
21
 * @author Doug Wright
22
 */
23
class VolumePacker implements LoggerAwareInterface
24
{
25
    /**
26
     * The logger instance.
27
     *
28
     * @var LoggerInterface
29
     */
30
    protected $logger;
31
32
    /**
33
     * Box to pack items into.
34
     *
35
     * @var Box
36
     */
37
    protected $box;
38
39
    /**
40
     * @var int
41
     */
42
    protected $boxWidth;
43
44
    /**
45
     * @var int
46
     */
47
    protected $boxLength;
48
49
    /**
50
     * List of items to be packed.
51
     *
52
     * @var ItemList
53
     */
54
    protected $items;
55
56
    /**
57
     * List of items temporarily skipped to be packed.
58
     *
59
     * @var array
60
     */
61
    protected $skippedItems = [];
62
63
    /**
64
     * Remaining weight capacity of the box.
65
     *
66
     * @var int
67
     */
68
    protected $remainingWeight;
69
70
    /**
71
     * Whether the box was rotated for packing.
72
     *
73
     * @var bool
74
     */
75
    protected $boxRotated = false;
76
77
    /**
78
     * @var PackedLayer[]
79
     */
80
    protected $layers = [];
81
82
    /**
83
     * Whether the packer is in look-ahead mode (i.e. working ahead of the main packing).
84
     *
85
     * @var bool
86
     */
87
    protected $lookAheadMode = false;
88
89
    /**
90
     * @var OrientatedItemFactory
91
     */
92
    private $orientatedItemFactory;
93
94
    /**
95
     * Constructor.
96
     */
97 22
    public function __construct(Box $box, ItemList $items)
98
    {
99 22
        $this->box = $box;
100 22
        $this->items = clone $items;
101
102 22
        $this->boxWidth = max($this->box->getInnerWidth(), $this->box->getInnerLength());
103 22
        $this->boxLength = min($this->box->getInnerWidth(), $this->box->getInnerLength());
104 22
        $this->remainingWeight = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
105 22
        $this->logger = new NullLogger();
106
107
        // we may have just rotated the box for packing purposes, record if we did
108 22
        if ($this->box->getInnerWidth() !== $this->boxWidth || $this->box->getInnerLength() !== $this->boxLength) {
109 4
            $this->boxRotated = true;
110
        }
111
112 22
        $this->orientatedItemFactory = new OrientatedItemFactory($this->box);
113 22
        $this->orientatedItemFactory->setLogger($this->logger);
114 22
    }
115
116
    /**
117
     * Sets a logger.
118
     */
119 7
    public function setLogger(LoggerInterface $logger): void
120
    {
121 7
        $this->logger = $logger;
122 7
        $this->orientatedItemFactory->setLogger($logger);
123 7
    }
124
125
    /**
126
     * @internal
127
     */
128 3
    public function setLookAheadMode(bool $lookAhead): void
129
    {
130 3
        $this->lookAheadMode = $lookAhead;
131 3
    }
132
133
    /**
134
     * Pack as many items as possible into specific given box.
135
     *
136
     * @return PackedBox packed box
137
     */
138 22
    public function pack(): PackedBox
139
    {
140 22
        $this->logger->debug("[EVALUATING BOX] {$this->box->getReference()}", ['box' => $this->box]);
141
142 22
        while ($this->items->count() > 0) {
143 22
            $layerStartDepth = $this->getCurrentPackedDepth();
144 22
            $this->packLayer($layerStartDepth, $this->boxWidth, $this->boxLength, $this->box->getInnerDepth() - $layerStartDepth);
145
        }
146
147 22
        if ($this->boxRotated) {
148 4
            $this->rotateLayersNinetyDegrees();
149
        }
150
151 22
        if (!$this->lookAheadMode) {
152 22
            $this->stabiliseLayers();
153
        }
154
155 22
        $this->logger->debug('done with this box ' . $this->box->getReference());
156
157 22
        return new PackedBox($this->box, $this->getPackedItemList());
158
    }
159
160
    /**
161
     * Pack items into an individual vertical layer.
162
     */
163 22
    protected function packLayer(int $startDepth, int $widthLeft, int $lengthLeft, int $depthLeft): void
164
    {
165 22
        $this->layers[] = $layer = new PackedLayer();
166 22
        $prevItem = null;
167 22
        $x = $y = $rowWidth = $rowLength = $layerDepth = 0;
168
169 22
        while ($this->items->count() > 0) {
170 22
            $itemToPack = $this->items->extract();
171
172
            //skip items that are simply too heavy or too large
173 22
            if (!$this->checkNonDimensionalConstraints($itemToPack)) {
174 3
                continue;
175
            }
176
177 22
            $orientatedItem = $this->getOrientationForItem($itemToPack, $prevItem, $this->items, !$this->hasItemsLeftToPack(), $widthLeft, $lengthLeft, $depthLeft, $rowLength, $x, $y, $startDepth);
178
179 22
            if ($orientatedItem instanceof OrientatedItem) {
180 22
                $packedItem = PackedItem::fromOrientatedItem($orientatedItem, $x, $y, $startDepth);
181 22
                $layer->insert($packedItem);
182 22
                $this->remainingWeight -= $orientatedItem->getItem()->getWeight();
183 22
                $widthLeft -= $orientatedItem->getWidth();
184
185 22
                $rowWidth += $orientatedItem->getWidth();
186 22
                $rowLength = max($rowLength, $orientatedItem->getLength());
187 22
                $layerDepth = max($layerDepth, $orientatedItem->getDepth());
188
189
                //allow items to be stacked in place within the same footprint up to current layer depth
190 22
                $stackableDepth = $layerDepth - $orientatedItem->getDepth();
191 22
                $this->tryAndStackItemsIntoSpace($layer, $prevItem, $this->items, $orientatedItem->getWidth(), $orientatedItem->getLength(), $stackableDepth, $x, $y, $startDepth + $orientatedItem->getDepth(), $rowLength);
192 22
                $x += $orientatedItem->getWidth();
193
194 22
                $prevItem = $packedItem;
195 22
                if ($this->items->count() === 0) {
196 22
                    $this->rebuildItemList();
197
                }
198 19
            } elseif (count($layer->getItems()) === 0) { // zero items on layer
199 5
                $this->logger->debug("doesn't fit on layer even when empty, skipping for good");
200 5
                continue;
201 18
            } elseif ($widthLeft > 0 && $this->items->count() > 0) { // skip for now, move on to the next item
202 14
                $this->logger->debug("doesn't fit, skipping for now");
203 14
                $this->skippedItems[] = $itemToPack;
204
                // 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
205 14
                while ($this->items->count() > 2 && static::isSameDimensions($itemToPack, $this->items->top())) {
206 4
                    $this->skippedItems[] = $this->items->extract();
207
                }
208 18
            } elseif ($x > 0 && $lengthLeft >= min($itemToPack->getWidth(), $itemToPack->getLength(), $itemToPack->getDepth())) {
209 18
                $this->logger->debug('No more fit in width wise, resetting for new row');
210 18
                $widthLeft += $rowWidth;
211 18
                $lengthLeft -= $rowLength;
212 18
                $y += $rowLength;
213 18
                $x = $rowWidth = $rowLength = 0;
214 18
                $this->skippedItems[] = $itemToPack;
215 18
                $this->rebuildItemList();
216 18
                $prevItem = null;
217 18
                continue;
218
            } else {
219 16
                $this->logger->debug('no items fit, so starting next vertical layer');
220 16
                $this->skippedItems[] = $itemToPack;
221 16
                $this->rebuildItemList();
222
223 16
                return;
224
            }
225
        }
226 22
    }
227
228
    /**
229
     * During packing, it is quite possible that layers have been created that aren't physically stable
230
     * i.e. they overhang the ones below.
231
     *
232
     * This function reorders them so that the ones with the greatest surface area are placed at the bottom
233
     */
234 22
    public function stabiliseLayers(): void
235
    {
236 22
        $stabiliser = new LayerStabiliser();
237 22
        $stabiliser->setLogger($this->logger);
238 22
        $this->layers = $stabiliser->stabilise($this->layers);
239 22
    }
240
241 22
    protected function getOrientationForItem(
242
        Item $itemToPack,
243
        ?PackedItem $prevItem,
244
        ItemList $nextItems,
245
        bool $isLastItem,
246
        int $maxWidth,
247
        int $maxLength,
248
        int $maxDepth,
249
        int $rowLength,
250
        int $x,
251
        int $y,
252
        int $z
253
    ): ?OrientatedItem {
254 22
        $this->logger->debug(
255 22
            "evaluating item {$itemToPack->getDescription()} for fit",
256
            [
257 22
                'item' => $itemToPack,
258
                'space' => [
259 22
                    'maxWidth' => $maxWidth,
260 22
                    'maxLength' => $maxLength,
261 22
                    'maxDepth' => $maxDepth,
262
                ],
263
            ]
264
        );
265
266 22
        $prevOrientatedItem = $prevItem ? $prevItem->toOrientatedItem() : null;
267 22
        $prevPackedItemList = $itemToPack instanceof ConstrainedPlacementItem ? $this->getPackedItemList() : new PackedItemList(); // don't calculate it if not going to be used
268
269 22
        $orientatedItemDecision = $this->orientatedItemFactory->getBestOrientation($itemToPack, $prevOrientatedItem, $nextItems, $isLastItem, $maxWidth, $maxLength, $maxDepth, $rowLength, $x, $y, $z, $prevPackedItemList);
270
271 22
        return $orientatedItemDecision;
272
    }
273
274
    /**
275
     * Figure out if we can stack the next item vertically on top of this rather than side by side
276
     * Used when we've packed a tall item, and have just put a shorter one next to it.
277
     */
278 22
    protected function tryAndStackItemsIntoSpace(
279
        PackedLayer $layer,
280
        ?PackedItem $prevItem,
281
        ItemList $nextItems,
282
        int $maxWidth,
283
        int $maxLength,
284
        int $maxDepth,
285
        int $x,
286
        int $y,
287
        int $z,
288
        int $rowLength
289
    ): void {
290 22
        while ($this->items->count() > 0 && $this->checkNonDimensionalConstraints($this->items->top())) {
291 21
            $stackedItem = $this->getOrientationForItem(
292 21
                $this->items->top(),
293 21
                $prevItem,
294 21
                $nextItems,
295 21
                $this->items->count() === 1,
296 21
                $maxWidth,
297 21
                $maxLength,
298 21
                $maxDepth,
299 21
                $rowLength,
300 21
                $x,
301 21
                $y,
302 21
                $z
303
            );
304 21
            if ($stackedItem) {
305 3
                $this->remainingWeight -= $this->items->top()->getWeight();
306 3
                $layer->insert(PackedItem::fromOrientatedItem($stackedItem, $x, $y, $z));
307 3
                $this->items->extract();
308 3
                $maxDepth -= $stackedItem->getDepth();
309 3
                $z += $stackedItem->getDepth();
310
            } else {
311 21
                break;
312
            }
313
        }
314 22
    }
315
316
    /**
317
     * As well as purely dimensional constraints, there are other constraints that need to be met
318
     * e.g. weight limits or item-specific restrictions (e.g. max <x> batteries per box).
319
     */
320 22
    protected function checkNonDimensionalConstraints(Item $itemToPack): bool
321
    {
322 22
        $customConstraintsOK = true;
323 22
        if ($itemToPack instanceof ConstrainedItem) {
324
            $customConstraintsOK = $itemToPack->canBePackedInBox($this->getPackedItemList(), $this->box);
325
        }
326
327 22
        return $customConstraintsOK && $itemToPack->getWeight() <= $this->remainingWeight;
328
    }
329
330
    /**
331
     * Reintegrate skipped items into main list.
332
     */
333 22
    protected function rebuildItemList(): void
334
    {
335 22
        $this->items = ItemList::fromArray(array_merge($this->skippedItems, iterator_to_array($this->items)), true);
336 22
        $this->skippedItems = [];
337 22
    }
338
339
    /**
340
     * Swap back width/length of the packed items to match orientation of the box if needed.
341
     */
342 4
    protected function rotateLayersNinetyDegrees(): void
343
    {
344 4
        foreach ($this->layers as $i => $originalLayer) {
345 4
            $newLayer = new PackedLayer();
346 4
            foreach ($originalLayer->getItems() as $item) {
347 4
                $packedItem = new PackedItem($item->getItem(), $item->getY(), $item->getX(), $item->getZ(), $item->getLength(), $item->getWidth(), $item->getDepth());
348 4
                $newLayer->insert($packedItem);
349
            }
350 4
            $this->layers[$i] = $newLayer;
351
        }
352 4
    }
353
354
    /**
355
     * Are there items left to pack?
356
     */
357 22
    protected function hasItemsLeftToPack(): bool
358
    {
359 22
        return count($this->skippedItems) + $this->items->count() > 0;
360
    }
361
362
    /**
363
     * Generate a single list of items packed.
364
     */
365 22
    protected function getPackedItemList(): PackedItemList
366
    {
367 22
        $packedItemList = new PackedItemList();
368 22
        foreach ($this->layers as $layer) {
369 22
            foreach ($layer->getItems() as $packedItem) {
370 22
                $packedItemList->insert($packedItem);
371
            }
372
        }
373
374 22
        return $packedItemList;
375
    }
376
377
    /**
378
     * Return the current packed depth.
379
     */
380 22
    protected function getCurrentPackedDepth(): int
381
    {
382 22
        $depth = 0;
383 22
        foreach ($this->layers as $layer) {
384 16
            $depth += $layer->getDepth();
385
        }
386
387 22
        return $depth;
388
    }
389
390
    /**
391
     * Compare two items to see if they have same dimensions.
392
     */
393 4
    protected static function isSameDimensions(Item $itemA, Item $itemB): bool
394
    {
395 4
        if ($itemA === $itemB) {
396 4
            return true;
397
        }
398 1
        $itemADimensions = [$itemA->getWidth(), $itemA->getLength(), $itemA->getDepth()];
399 1
        $itemBDimensions = [$itemB->getWidth(), $itemB->getLength(), $itemB->getDepth()];
400 1
        sort($itemADimensions);
401 1
        sort($itemBDimensions);
402
403 1
        return $itemADimensions === $itemBDimensions;
404
    }
405
}
406