Completed
Branch constraints (78d212)
by Doug
02:02
created

VolumePacker   B

Complexity

Total Complexity 38

Size/Duplication

Total Lines 352
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 9

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
dl 0
loc 352
c 0
b 0
f 0
wmc 38
lcom 1
cbo 9
ccs 131
cts 131
cp 1
rs 8.3999

7 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 12 1
C pack() 0 98 12
C findBestOrientation() 0 66 9
B findPossibleOrientations() 0 29 6
A tryAndStackItemsIntoSpace() 0 13 4
A isLayerStarted() 0 4 3
A checkNonDimensionalConstraints() 0 10 3
1
<?php
2
/**
3
 * Box packing (3D bin packing, knapsack problem)
4
 * @package BoxPacker
5
 * @author Doug Wright
6
 */
7
namespace DVDoug\BoxPacker;
8
9
use Psr\Log\LoggerAwareInterface;
10
use Psr\Log\LoggerAwareTrait;
11
use Psr\Log\NullLogger;
12
13
/**
14
 * Actual packer
15
 * @author Doug Wright
16
 * @package BoxPacker
17
 */
18
class VolumePacker implements LoggerAwareInterface
19
{
20
    use LoggerAwareTrait;
21
22
    /**
23
     * Box to pack items into
24
     * @var Box
25
     */
26
    protected $box;
27
28
    /**
29
     * List of items to be packed
30
     * @var ItemList
31
     */
32
    protected $items;
33
34
    /**
35
     * Remaining width of the box to pack items into
36
     * @var int
37
     */
38
    protected $widthLeft;
39
40
    /**
41
     * Remaining length of the box to pack items into
42
     * @var int
43
     */
44
    protected $lengthLeft;
45
46
    /**
47
     * Remaining depth of the box to pack items into
48
     * @var int
49
     */
50
    protected $depthLeft;
51
52
    /**
53
     * Remaining weight capacity of the box
54
     * @var int
55
     */
56
    protected $remainingWeight;
57
58
    /**
59
     * Used width inside box for packing items
60
     * @var int
61
     */
62
    protected $usedWidth = 0;
63
64
    /**
65
     * Used length inside box for packing items
66
     * @var int
67
     */
68
    protected $usedLength = 0;
69
70
    /**
71
     * Used depth inside box for packing items
72
     * @var int
73
     */
74
    protected $usedDepth = 0;
75
76
    /**
77
     * Constructor
78
     *
79
     * @param Box      $box
80
     * @param ItemList $items
81
     */
82 31
    public function __construct(Box $box, ItemList $items)
83
    {
84 31
        $this->logger = new NullLogger();
85
86 31
        $this->box = $box;
87 31
        $this->items = $items;
88
89 31
        $this->depthLeft = $this->box->getInnerDepth();
90 31
        $this->remainingWeight = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
91 31
        $this->widthLeft = $this->box->getInnerWidth();
92 31
        $this->lengthLeft = $this->box->getInnerLength();
93
    }
94
95
    /**
96
     * Pack as many items as possible into specific given box
97
     * @return PackedBox packed box
98
     */
99 31
    public function pack()
100
    {
101 31
        $this->logger->debug("[EVALUATING BOX] {$this->box->getReference()}");
102
103 31
        $packedItems = new ItemList;
104
105 31
        $layerWidth = $layerLength = $layerDepth = 0;
106
107 31
        $prevItem = null;
108
109 31
        while (!$this->items->isEmpty()) {
110
111 31
            $itemToPack = $this->items->extract();
112
113
            //skip items that are simply too heavy
114 31
            if (!$this->checkNonDimensionalConstraints($itemToPack, $packedItems)) {
115 5
                continue;
116
            }
117
118 31
            $this->logger->debug(
119 31
                "evaluating item {$itemToPack->getDescription()} for fit",
120
                [
121 31
                    'item' => $itemToPack,
122
                    'space' => [
123 31
                        'widthLeft'   => $this->widthLeft,
124 31
                        'lengthLeft'  => $this->lengthLeft,
125 31
                        'depthLeft'   => $this->depthLeft,
126 31
                        'layerWidth'  => $layerWidth,
127 31
                        'layerLength' => $layerLength,
128 31
                        'layerDepth'  => $layerDepth
129
                    ]
130
                ]
131
            );
132
133 31
            $nextItem = !$this->items->isEmpty() ? $this->items->top() : null;
134 31
            $orientatedItem = $this->findBestOrientation($itemToPack, $prevItem, $nextItem, $this->widthLeft, $this->lengthLeft, $this->depthLeft);
135
136 31
            if ($orientatedItem) {
137
138 31
                $packedItems->insert($orientatedItem->getItem());
139 31
                $this->remainingWeight -= $itemToPack->getWeight();
140
141 31
                $this->lengthLeft -= $orientatedItem->getLength();
142 31
                $layerLength += $orientatedItem->getLength();
143 31
                $layerWidth = max($orientatedItem->getWidth(), $layerWidth);
144
145 31
                $layerDepth = max($layerDepth, $orientatedItem->getDepth()); //greater than 0, items will always be less deep
146
147 31
                $this->usedLength = max($this->usedLength, $layerLength);
148 31
                $this->usedWidth = max($this->usedWidth, $layerWidth);
149
150
                //allow items to be stacked in place within the same footprint up to current layerdepth
151 31
                $stackableDepth = $layerDepth - $orientatedItem->getDepth();
152 31
                $this->tryAndStackItemsIntoSpace($packedItems, $prevItem, $nextItem, $orientatedItem->getWidth(), $orientatedItem->getLength(), $stackableDepth);
153
154 31
                $prevItem = $orientatedItem;
155
156 31
                if (!$nextItem) {
157 31
                    $this->usedDepth += $layerDepth;
158
                }
159
            } else {
160
161 24
                $prevItem = null;
162
163 24
                if ($this->widthLeft >= min($itemToPack->getWidth(), $itemToPack->getLength()) && $this->isLayerStarted($layerWidth, $layerLength, $layerDepth)) {
164 23
                    $this->logger->debug("No more fit in lengthwise, resetting for new row");
165 23
                    $this->lengthLeft += $layerLength;
166 23
                    $this->widthLeft -= $layerWidth;
167 23
                    $layerWidth = $layerLength = 0;
168 23
                    $this->items->insert($itemToPack);
169 23
                    continue;
170 18
                } elseif ($this->lengthLeft < min($itemToPack->getWidth(), $itemToPack->getLength()) || $layerDepth == 0) {
171 7
                    $this->logger->debug("doesn't fit on layer even when empty");
172 7
                    continue;
173
                }
174
175 17
                $this->widthLeft = $layerWidth ? min(floor($layerWidth * 1.1), $this->box->getInnerWidth()) : $this->box->getInnerWidth();
0 ignored issues
show
Documentation Bug introduced by
It seems like $layerWidth ? min(floor(...s->box->getInnerWidth() can also be of type double. However, the property $widthLeft is declared as type integer. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
176 17
                $this->lengthLeft = $layerLength ? min(floor($layerLength * 1.1), $this->box->getInnerLength()) : $this->box->getInnerLength();
0 ignored issues
show
Documentation Bug introduced by
It seems like $layerLength ? min(floor...->box->getInnerLength() can also be of type double. However, the property $lengthLeft is declared as type integer. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
177 17
                $this->depthLeft -= $layerDepth;
178 17
                $this->usedDepth += $layerDepth;
179
180 17
                $layerWidth = $layerLength = $layerDepth = 0;
181 17
                $this->logger->debug("doesn't fit, so starting next vertical layer");
182 17
                $this->items->insert($itemToPack);
183
            }
184
        }
185 31
        $this->logger->debug("done with this box");
186 31
        return new PackedBox(
187 31
            $this->box,
188
            $packedItems,
189 31
            $this->widthLeft,
190 31
            $this->lengthLeft,
191 31
            $this->depthLeft,
192 31
            $this->remainingWeight,
193 31
            $this->usedWidth,
194 31
            $this->usedLength,
195 31
            $this->usedDepth);
196
    }
197
198
    /**
199
     * Get the best orientation for an item
200
     * @param Item $item
201
     * @param OrientatedItem|null $prevItem
202
     * @param Item|null $nextItem
203
     * @param int $widthLeft
204
     * @param int $lengthLeft
205
     * @param int $depthLeft
206
     * @return OrientatedItem|false
207
     */
208 31
    protected function findBestOrientation(Item $item, OrientatedItem $prevItem = null, Item $nextItem = null, $widthLeft, $lengthLeft, $depthLeft)
209
    {
210
211 31
        $orientations = $this->findPossibleOrientations($item, $prevItem, $widthLeft, $lengthLeft, $depthLeft);
212
213
        /*
214
         * Divide possible orientations into stable (low centre of gravity) and unstable (high centre of gravity)
215
         */
216 31
        $stableOrientations = [];
217 31
        $unstableOrientations = [];
218
219 31
        foreach ($orientations as $o => $orientation) {
220 31
            if ($orientation->isStable()) {
221 28
                $stableOrientations[] = $orientation;
222
            } else {
223 31
                $unstableOrientations[] = $orientation;
224
            }
225
        }
226
227 31
        $orientationsToUse = [];
228
229
        /*
230
         * We prefer to use stable orientations only, but allow unstable ones if either
231
         * the item is the last one left to pack OR
232
         * the item doesn't fit in the box any other way
233
         */
234 31
        if (count($stableOrientations) > 0) {
235 28
            $orientationsToUse = $stableOrientations;
236 30
        } else if (count($unstableOrientations) > 0) {
237 4
            $orientationsInEmptyBox = $this->findPossibleOrientations(
238
                $item,
239
                $prevItem,
240 4
                $this->box->getInnerWidth(),
241 4
                $this->box->getInnerLength(),
242 4
                $this->box->getInnerDepth()
243
            );
244
245 4
            $stableOrientationsInEmptyBox = array_filter(
246
                $orientationsInEmptyBox,
247
                function(OrientatedItem $orientation) {
248 4
                    return $orientation->isStable();
249 4
                }
250
            );
251
252 4
            if (is_null($nextItem) || count($stableOrientationsInEmptyBox) == 0) {
253 4
                $orientationsToUse = $unstableOrientations;
254
            }
255
        }
256
257 31
        $orientationFits = [];
258
        /** @var OrientatedItem $orientation */
259 31
        foreach ($orientationsToUse as $o => $orientation) {
260 31
            $orientationFit = min($widthLeft - $orientation->getWidth(), $lengthLeft  - $orientation->getLength());
261 31
            $orientationFits[$o] = $orientationFit;
262
        }
263
264 31
        if (!empty($orientationFits)) {
265 31
            asort($orientationFits);
266 31
            reset($orientationFits);
267 31
            $bestFit = $orientationsToUse[key($orientationFits)];
268 31
            $this->logger->debug("Selected best fit orientation", ['orientation' => $bestFit]);
269 31
            return $bestFit;
270
        } else {
271 29
            return false;
272
        }
273
    }
274
275
    /**
276
     * Find all possible orientations for an item
277
     * @param Item $item
278
     * @param OrientatedItem|null $prevItem
279
     * @param int $widthLeft
280
     * @param int $lengthLeft
281
     * @param int $depthLeft
282
     * @return OrientatedItem[]
283
     */
284 31
    protected function findPossibleOrientations(Item $item, OrientatedItem $prevItem = null, $widthLeft, $lengthLeft, $depthLeft)
285
    {
286
287 31
        $orientations = [];
288
289
        //Special case items that are the same as what we just packed - keep orientation
290 31
        if ($prevItem && $prevItem->getItem() == $item) {
291 13
            $orientations[] = new OrientatedItem($item, $prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth());
292
        } else {
293
294
            //simple 2D rotation
295 31
            $orientations[] = new OrientatedItem($item, $item->getWidth(), $item->getLength(), $item->getDepth());
296 31
            $orientations[] = new OrientatedItem($item, $item->getLength(), $item->getWidth(), $item->getDepth());
297
298
            //add 3D rotation if we're allowed
299 31
            if (!$item->getKeepFlat()) {
300 12
                $orientations[] = new OrientatedItem($item, $item->getWidth(), $item->getDepth(), $item->getLength());
301 12
                $orientations[] = new OrientatedItem($item, $item->getLength(), $item->getDepth(), $item->getWidth());
302 12
                $orientations[] = new OrientatedItem($item, $item->getDepth(), $item->getWidth(), $item->getLength());
303 12
                $orientations[] = new OrientatedItem($item, $item->getDepth(), $item->getLength(), $item->getWidth());
304
            }
305
        }
306
307
        //remove any that simply don't fit
308 31
        return array_filter($orientations, function (OrientatedItem $i) use ($widthLeft, $lengthLeft, $depthLeft) {
309 31
            return $i->getWidth() <= $widthLeft && $i->getLength() <= $lengthLeft && $i->getDepth() <= $depthLeft;
310 31
        });
311
312
    }
313
314
    /**
315
     * Figure out if we can stack the next item vertically on top of this rather than side by side
316
     * Used when we've packed a tall item, and have just put a shorter one next to it
317
     *
318
     * @param ItemList       $packedItems
319
     * @param OrientatedItem $prevItem
0 ignored issues
show
Documentation introduced by
Should the type for parameter $prevItem not be null|OrientatedItem?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
320
     * @param Item           $nextItem
0 ignored issues
show
Documentation introduced by
Should the type for parameter $nextItem not be null|Item?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
321
     * @param int            $maxWidth
322
     * @param int            $maxLength
323
     * @param int            $maxDepth
324
     */
325 31
    protected function tryAndStackItemsIntoSpace(ItemList $packedItems, OrientatedItem $prevItem = null, Item $nextItem = null, $maxWidth, $maxLength, $maxDepth)
326
    {
327 31
        while (!$this->items->isEmpty() && $this->remainingWeight >= $this->items->top()->getWeight()) {
328 29
            $stackedItem = $this->findBestOrientation($this->items->top(), $prevItem, $nextItem, $maxWidth, $maxLength, $maxDepth);
329 29
            if ($stackedItem) {
330 2
                $this->remainingWeight -= $this->items->top()->getWeight();
331 2
                $maxDepth -= $stackedItem->getDepth();
332 2
                $packedItems->insert($this->items->extract());
333
            } else {
334 29
                break;
335
            }
336
        }
337
    }
338
339
    /**
340
     * @param int $layerWidth
341
     * @param int $layerLength
342
     * @param int $layerDepth
343
     * @return bool
344
     */
345 24
    protected function isLayerStarted($layerWidth, $layerLength, $layerDepth)
346
    {
347 24
        return $layerWidth > 0 && $layerLength > 0 && $layerDepth > 0;
348
    }
349
350
    /**
351
     * As well as purely dimensional constraints, there are other constraints that need to be met
352
     * e.g. weight limits or item-specific restrictions (e.g. max <x> batteries per box)
353
     *
354
     * @param Item     $itemToPack
355
     * @param ItemList $packedItems
356
     *
357
     * @return bool
358
     */
359 31
    protected function checkNonDimensionalConstraints(Item $itemToPack, ItemList $packedItems)
360
    {
361 31
        $weightOK = $itemToPack->getWeight() <= $this->remainingWeight;
362
363 31
        if ($itemToPack instanceof ConstrainedItem) {
364 1
            return $weightOK && $itemToPack->canBePackedInBox(clone $packedItems, $this->box);
365
        }
366
367 30
        return $weightOK;
368
    }
369
}
370