Passed
Push — 3.x ( 071761...f78397 )
by Doug
03:06
created

Packer::beStrictAboutItemOrdering()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
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 array_merge;
12
use function count;
13
use const PHP_INT_MAX;
14
use Psr\Log\LoggerAwareInterface;
15
use Psr\Log\LoggerAwareTrait;
16
use Psr\Log\LogLevel;
17
use Psr\Log\NullLogger;
18
use SplObjectStorage;
19
use function usort;
20
21
/**
22
 * Actual packer.
23
 *
24
 * @author Doug Wright
25
 */
26
class Packer implements LoggerAwareInterface
27
{
28
    use LoggerAwareTrait;
29
30
    /**
31
     * Number of boxes at which balancing weight is deemed not worth it.
32
     *
33
     * @var int
34
     */
35
    protected $maxBoxesToBalanceWeight = 12;
36
37
    /**
38
     * List of items to be packed.
39
     *
40
     * @var ItemList
41
     */
42
    protected $items;
43
44
    /**
45
     * List of box sizes available to pack items into.
46
     *
47
     * @var BoxList
48
     */
49
    protected $boxes;
50
51
    /**
52
     * Quantities available of each box type.
53
     *
54
     * @var SplObjectStorage
55
     */
56
    protected $boxesQtyAvailable;
57
58
    /**
59
     * @var PackedBoxSorter
60
     */
61
    protected $packedBoxSorter;
62
63
    /**
64
     * @var bool
65
     */
66
    private $beStrictAboutItemOrdering = false;
67
68 41
    public function __construct()
69
    {
70 41
        $this->items = new ItemList();
71 41
        $this->boxes = new BoxList();
72 41
        $this->boxesQtyAvailable = new SplObjectStorage();
73 41
        $this->packedBoxSorter = new DefaultPackedBoxSorter();
74
75 41
        $this->logger = new NullLogger();
76 41
    }
77
78
    /**
79
     * Add item to be packed.
80
     */
81 26
    public function addItem(Item $item, int $qty = 1): void
82
    {
83 26
        $this->items->insert($item, $qty);
84 26
        $this->logger->log(LogLevel::INFO, "added {$qty} x {$item->getDescription()}", ['item' => $item]);
0 ignored issues
show
Bug introduced by
The method log() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

84
        $this->logger->/** @scrutinizer ignore-call */ 
85
                       log(LogLevel::INFO, "added {$qty} x {$item->getDescription()}", ['item' => $item]);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
85 26
    }
86
87
    /**
88
     * Set a list of items all at once.
89
     * @param iterable|Item[] $items
90
     */
91 17
    public function setItems(iterable $items): void
92
    {
93 17
        if ($items instanceof ItemList) {
94 14
            $this->items = clone $items;
95
        } else {
96 4
            $this->items = new ItemList();
97 4
            foreach ($items as $item) {
98 4
                $this->items->insert($item);
99
            }
100
        }
101 17
    }
102
103
    /**
104
     * Add box size.
105
     */
106 27
    public function addBox(Box $box): void
107
    {
108 27
        $this->boxes->insert($box);
109 27
        $this->setBoxQuantity($box, $box instanceof LimitedSupplyBox ? $box->getQuantityAvailable() : PHP_INT_MAX);
110 27
        $this->logger->log(LogLevel::INFO, "added box {$box->getReference()}", ['box' => $box]);
111 27
    }
112
113
    /**
114
     * Add a pre-prepared set of boxes all at once.
115
     */
116 16
    public function setBoxes(BoxList $boxList): void
117
    {
118 16
        $this->boxes = $boxList;
119 16
        foreach ($this->boxes as $box) {
120 16
            $this->setBoxQuantity($box, $box instanceof LimitedSupplyBox ? $box->getQuantityAvailable() : PHP_INT_MAX);
121
        }
122 16
    }
123
124
    /**
125
     * Set the quantity of this box type available.
126
     */
127 39
    public function setBoxQuantity(Box $box, int $qty): void
128
    {
129 39
        $this->boxesQtyAvailable[$box] = $qty;
130 39
    }
131
132
    /**
133
     * Number of boxes at which balancing weight is deemed not worth the extra computation time.
134
     */
135 1
    public function getMaxBoxesToBalanceWeight(): int
136
    {
137 1
        return $this->maxBoxesToBalanceWeight;
138
    }
139
140
    /**
141
     * Number of boxes at which balancing weight is deemed not worth the extra computation time.
142
     */
143 4
    public function setMaxBoxesToBalanceWeight(int $maxBoxesToBalanceWeight): void
144
    {
145 4
        $this->maxBoxesToBalanceWeight = $maxBoxesToBalanceWeight;
146 4
    }
147
148 1
    public function setPackedBoxSorter(PackedBoxSorter $packedBoxSorter): void
149
    {
150 1
        $this->packedBoxSorter = $packedBoxSorter;
151 1
    }
152
153 1
    public function beStrictAboutItemOrdering(bool $beStrict): void
154
    {
155 1
        $this->beStrictAboutItemOrdering = $beStrict;
156 1
    }
157
158
    /**
159
     * Pack items into boxes.
160
     */
161 40
    public function pack(): PackedBoxList
162
    {
163 40
        $packedBoxes = $this->doVolumePacking();
164
165
        //If we have multiple boxes, try and optimise/even-out weight distribution
166 37
        if (!$this->beStrictAboutItemOrdering && $packedBoxes->count() > 1 && $packedBoxes->count() <= $this->maxBoxesToBalanceWeight) {
167 12
            $redistributor = new WeightRedistributor($this->boxes, $this->packedBoxSorter, $this->boxesQtyAvailable);
168 12
            $redistributor->setLogger($this->logger);
0 ignored issues
show
Bug introduced by
It seems like $this->logger can also be of type null; however, parameter $logger of DVDoug\BoxPacker\WeightRedistributor::setLogger() does only seem to accept Psr\Log\LoggerInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

168
            $redistributor->setLogger(/** @scrutinizer ignore-type */ $this->logger);
Loading history...
169 12
            $packedBoxes = $redistributor->redistributeWeight($packedBoxes);
170
        }
171
172 37
        $this->logger->log(LogLevel::INFO, "[PACKING COMPLETED], {$packedBoxes->count()} boxes");
173
174 37
        return $packedBoxes;
175
    }
176
177
    /**
178
     * Pack items into boxes using the principle of largest volume item first.
179
     *
180
     * @throws NoBoxesAvailableException
181
     */
182 40
    public function doVolumePacking(bool $singlePassMode = false, bool $enforceSingleBox = false): PackedBoxList
183
    {
184 40
        $packedBoxes = new PackedBoxList($this->packedBoxSorter);
185
186
        //Keep going until everything packed
187 40
        while ($this->items->count()) {
188 39
            $packedBoxesIteration = [];
189
190
            //Loop through boxes starting with smallest, see what happens
191 39
            foreach ($this->getBoxList($enforceSingleBox) as $box) {
192 38
                $volumePacker = new VolumePacker($box, $this->items);
193 38
                $volumePacker->setLogger($this->logger);
0 ignored issues
show
Bug introduced by
It seems like $this->logger can also be of type null; however, parameter $logger of DVDoug\BoxPacker\VolumePacker::setLogger() does only seem to accept Psr\Log\LoggerInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

193
                $volumePacker->setLogger(/** @scrutinizer ignore-type */ $this->logger);
Loading history...
194 38
                $volumePacker->setSinglePassMode($singlePassMode);
195 38
                $volumePacker->beStrictAboutItemOrdering($this->beStrictAboutItemOrdering);
196 38
                $packedBox = $volumePacker->pack();
197 38
                if ($packedBox->getItems()->count()) {
198 37
                    $packedBoxesIteration[] = $packedBox;
199
200
                    //Have we found a single box that contains everything?
201 37
                    if ($packedBox->getItems()->count() === $this->items->count()) {
202 35
                        break;
203
                    }
204
                }
205
            }
206
207
            try {
208
                //Find best box of iteration, and remove packed items from unpacked list
209 39
                $bestBox = $this->findBestBoxFromIteration($packedBoxesIteration);
210 5
            } catch (NoBoxesAvailableException $e) {
211 5
                if ($enforceSingleBox) {
212 1
                    return new PackedBoxList($this->packedBoxSorter);
213
                }
214 4
                throw $e;
215
            }
216
217 37
            $this->items->removePackedItems($bestBox->getItems());
218
219 37
            $packedBoxes->insert($bestBox);
220 37
            $this->boxesQtyAvailable[$bestBox->getBox()] = $this->boxesQtyAvailable[$bestBox->getBox()] - 1;
221
        }
222
223 37
        return $packedBoxes;
224
    }
225
226
    /**
227
     * Get a "smart" ordering of the boxes to try packing items into. The initial BoxList is already sorted in order
228
     * so that the smallest boxes are evaluated first, but this means that time is spent on boxes that cannot possibly
229
     * hold the entire set of items due to volume limitations. These should be evaluated first.
230
     */
231 39
    protected function getBoxList(bool $enforceSingleBox = false): iterable
232
    {
233 39
        $itemVolume = 0;
234 39
        foreach ($this->items as $item) {
235 39
            $itemVolume += $item->getWidth() * $item->getLength() * $item->getDepth();
236
        }
237
238 39
        $preferredBoxes = [];
239 39
        $otherBoxes = [];
240 39
        foreach ($this->boxes as $box) {
241 38
            if ($this->boxesQtyAvailable[$box] > 0) {
242 38
                if ($box->getInnerWidth() * $box->getInnerLength() * $box->getInnerDepth() >= $itemVolume) {
243 35
                    $preferredBoxes[] = $box;
244 18
                } elseif (!$enforceSingleBox) {
245 18
                    $otherBoxes[] = $box;
246
                }
247
            }
248
        }
249
250 39
        return array_merge($preferredBoxes, $otherBoxes);
251
    }
252
253
    /**
254
     * @param PackedBox[] $packedBoxes
255
     */
256 39
    protected function findBestBoxFromIteration(array $packedBoxes): PackedBox
257
    {
258 39
        if (count($packedBoxes) === 0) {
259 5
            throw new NoBoxesAvailableException("No boxes could be found for item '{$this->items->top()->getDescription()}'", $this->items->top());
260
        }
261
262 37
        usort($packedBoxes, [$this->packedBoxSorter, 'compare']);
263
264 37
        return $packedBoxes[0];
265
    }
266
}
267