Passed
Push — 1.x ( 457fa0...308477 )
by Doug
03:11
created

Packer::doVolumePacking()   B

Complexity

Conditions 7
Paths 13

Size

Total Lines 40
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 20
CRAP Score 7.0052

Importance

Changes 0
Metric Value
cc 7
eloc 21
c 0
b 0
f 0
nc 13
nop 2
dl 0
loc 40
ccs 20
cts 21
cp 0.9524
crap 7.0052
rs 8.6506
1
<?php
2
/**
3
 * Box packing (3D bin packing, knapsack problem).
4
 *
5
 * @author Doug Wright
6
 */
7
namespace DVDoug\BoxPacker;
8
9
use Psr\Log\LoggerAwareInterface;
10
use Psr\Log\LoggerAwareTrait;
11
use Psr\Log\LogLevel;
12
use Psr\Log\NullLogger;
13
14
/**
15
 * Actual packer.
16
 *
17
 * @author Doug Wright
18
 */
19
class Packer implements LoggerAwareInterface
20
{
21
    use LoggerAwareTrait;
22
23
    /**
24
     * Number of boxes at which balancing weight is deemed not worth it.
25
     *
26
     * @var int
27
     */
28
    protected $maxBoxesToBalanceWeight = 12;
29
30
    /**
31
     * List of items to be packed.
32
     *
33
     * @var ItemList
34
     */
35
    protected $items;
36
37
    /**
38
     * List of box sizes available to pack items into.
39
     *
40
     * @var BoxList
41
     */
42
    protected $boxes;
43
44
    /**
45
     * Constructor.
46
     */
47 21
    public function __construct()
48
    {
49 21
        $this->items = new ItemList();
50 21
        $this->boxes = new BoxList();
51
52 21
        $this->logger = new NullLogger();
53 21
    }
54
55
    /**
56
     * Add item to be packed.
57
     *
58
     * @param Item $item
59
     * @param int  $qty
60
     */
61 15
    public function addItem(Item $item, $qty = 1)
62
    {
63 15
        for ($i = 0; $i < $qty; ++$i) {
64 15
            $this->items->insert($item);
65
        }
66 15
        $this->logger->log(LogLevel::INFO, "added {$qty} x {$item->getDescription()}", ['item' => $item]);
67 15
    }
68
69
    /**
70
     * Set a list of items all at once.
71
     *
72
     * @param iterable|Item[] $items
73
     */
74 9
    public function setItems($items)
75
    {
76 9
        if ($items instanceof ItemList) {
77 5
            $this->items = clone $items;
78
        } else {
79 4
            $this->items = new ItemList();
80 4
            foreach ($items as $item) {
81 4
                $this->items->insert($item);
82
            }
83
        }
84 9
    }
85
86
    /**
87
     * Add box size.
88
     *
89
     * @param Box $box
90
     */
91 14
    public function addBox(Box $box)
92
    {
93 14
        $this->boxes->insert($box);
94 14
        $this->logger->log(LogLevel::INFO, "added box {$box->getReference()}", ['box' => $box]);
95 14
    }
96
97
    /**
98
     * Add a pre-prepared set of boxes all at once.
99
     *
100
     * @param BoxList $boxList
101
     */
102 9
    public function setBoxes(BoxList $boxList)
103
    {
104 9
        $this->boxes = clone $boxList;
105 9
    }
106
107
    /**
108
     * Number of boxes at which balancing weight is deemed not worth the extra computation time.
109
     *
110
     * @return int
111
     */
112 1
    public function getMaxBoxesToBalanceWeight()
113
    {
114 1
        return $this->maxBoxesToBalanceWeight;
115
    }
116
117
    /**
118
     * Number of boxes at which balancing weight is deemed not worth the extra computation time.
119
     *
120
     * @param int $maxBoxesToBalanceWeight
121
     */
122 2
    public function setMaxBoxesToBalanceWeight($maxBoxesToBalanceWeight)
123
    {
124 2
        $this->maxBoxesToBalanceWeight = $maxBoxesToBalanceWeight;
125 2
    }
126
127
    /**
128
     * Pack items into boxes.
129
     *
130
     * @return PackedBoxList
131
     */
132 20
    public function pack()
133
    {
134 20
        $this->sanityPrecheck();
135 18
        $packedBoxes = $this->doVolumePacking();
136
137
        //If we have multiple boxes, try and optimise/even-out weight distribution
138 18
        if ($packedBoxes->count() > 1 && $packedBoxes->count() <= $this->maxBoxesToBalanceWeight) {
139 8
            $redistributor = new WeightRedistributor($this->boxes);
140 8
            $redistributor->setLogger($this->logger);
141 8
            $packedBoxes = $redistributor->redistributeWeight($packedBoxes);
142
        }
143
144 18
        $this->logger->log(LogLevel::INFO, "[PACKING COMPLETED], {$packedBoxes->count()} boxes");
145
146 18
        return $packedBoxes;
147
    }
148
149
    /**
150
     * Pack items into boxes using the principle of largest volume item first.
151
     *
152
     * @throws NoBoxesAvailableException
153
     *
154
     * @return PackedBoxList
155
     */
156 18
    public function doVolumePacking($singlePassMode = false, $enforceSingleBox = false)
157
    {
158 18
        $packedBoxes = new PackedBoxList();
159
160
        //Keep going until everything packed
161 18
        while ($this->items->count()) {
162 18
            $packedBoxesIteration = [];
163
164
            //Loop through boxes starting with smallest, see what happens
165 18
            foreach ($this->getBoxList($enforceSingleBox) as $box) {
166 18
                $volumePacker = new VolumePacker($box, $this->items);
167 18
                $volumePacker->setLogger($this->logger);
168 18
                $volumePacker->setSinglePassMode($singlePassMode);
169 18
                $packedBox = $volumePacker->pack();
170 18
                if ($packedBox->getItems()->count()) {
171 18
                    $packedBoxesIteration[] = $packedBox;
172
173
                    //Have we found a single box that contains everything?
174 18
                    if ($packedBox->getItems()->count() === $this->items->count()) {
175 18
                        break;
176
                    }
177
                }
178
            }
179
180
            try {
181
                //Find best box of iteration, and remove packed items from unpacked list
182 18
                $bestBox = $this->findBestBoxFromIteration($packedBoxesIteration);
183 1
            } catch (NoBoxesAvailableException $e) {
184 1
                if ($enforceSingleBox) {
185 1
                    return new PackedBoxList();
186
                }
187
                throw $e;
188
            }
189
190 18
            $this->items->removePackedItems($bestBox->getPackedItems());
191
192 18
            $packedBoxes->insert($bestBox);
193
        }
194
195 18
        return $packedBoxes;
196
    }
197
198
    /**
199
     * Get a "smart" ordering of the boxes to try packing items into. The initial BoxList is already sorted in order
200
     * so that the smallest boxes are evaluated first, but this means that time is spent on boxes that cannot possibly
201
     * hold the entire set of items due to volume limitations. These should be evaluated first.
202
     */
203 18
    protected function getBoxList($enforceSingleBox = false)
204
    {
205 18
        $itemVolume = 0;
206 18
        foreach (clone $this->items as $item) {
207 18
            $itemVolume += $item->getWidth() * $item->getLength() * $item->getDepth();
208
        }
209
210 18
        $preferredBoxes = [];
211 18
        $otherBoxes = [];
212 18
        foreach (clone $this->boxes as $box) {
213 18
            if ($box->getInnerWidth() * $box->getInnerLength() * $box->getInnerDepth() >= $itemVolume) {
214 18
                $preferredBoxes[] = $box;
215 6
            } elseif (!$enforceSingleBox) {
216 6
                $otherBoxes[] = $box;
217
            }
218
        }
219
220 18
        return array_merge($preferredBoxes, $otherBoxes);
221
    }
222
223
    /**
224
     * @param PackedBox[] $packedBoxes
225
     */
226 18
    protected function findBestBoxFromIteration(array $packedBoxes)
227
    {
228 18
        if (count($packedBoxes) === 0) {
229 1
            throw new NoBoxesAvailableException("No boxes could be found for item '{$this->items->top()->getDescription()}'", $this->items->top());
230
        }
231
232 18
        usort($packedBoxes, [$this, 'compare']);
233
234 18
        return $packedBoxes[0];
235
    }
236
237 20
    private function sanityPrecheck()
238
    {
239
        /** @var Item $item */
240 20
        foreach (clone $this->items as $item) {
241 20
            $possibleFits = 0;
242
243
            /** @var Box $box */
244 20
            foreach (clone $this->boxes as $box) {
245 19
                if ($item->getWeight() <= ($box->getMaxWeight() - $box->getEmptyWeight())) {
246 19
                    $possibleFits += count((new OrientatedItemFactory($box))->getPossibleOrientationsInEmptyBox($item));
247
                }
248
            }
249
250 20
            if ($possibleFits === 0) {
251 2
                throw new ItemTooLargeException("Item '{$item->getDescription()}' is too large to fit into any box", $item);
252
            }
253
        }
254 18
    }
255
256 3
    private static function compare(PackedBox $boxA, PackedBox $boxB)
257
    {
258 3
        $choice = $boxB->getItems()->count() - $boxA->getItems()->count();
259
260 3
        if ($choice == 0) {
261 2
            $choice = $boxB->getVolumeUtilisation() - $boxA->getVolumeUtilisation();
262
        }
263 3
        if ($choice == 0) {
264 1
            $choice = $boxB->getUsedVolume() - $boxA->getUsedVolume();
265
        }
266 3
        if ($choice == 0) {
267
            $choice = PHP_MAJOR_VERSION > 5 ? -1 : 1;
268
        }
269
270 3
        return $choice;
271
    }
272
273
    /**
274
     * Pack as many items as possible into specific given box.
275
     *
276
     * @deprecated
277
     *
278
     * @param Box      $box
279
     * @param ItemList $items
280
     *
281
     * @return PackedBox packed box
282
     */
283
    public function packIntoBox(Box $box, ItemList $items)
284
    {
285
        $volumePacker = new VolumePacker($box, clone $items);
286
        $volumePacker->setLogger($this->logger);
287
288
        return $volumePacker->pack();
289
    }
290
291
    /**
292
     * Pack as many items as possible into specific given box.
293
     *
294
     * @deprecated
295
     *
296
     * @param Box      $box
297
     * @param ItemList $items
298
     *
299
     * @return ItemList items packed into box
300
     */
301
    public function packBox(Box $box, ItemList $items)
302
    {
303
        $packedBox = $this->packIntoBox($box, $items);
0 ignored issues
show
Deprecated Code introduced by
The function DVDoug\BoxPacker\Packer::packIntoBox() has been deprecated. ( Ignorable by Annotation )

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

303
        $packedBox = /** @scrutinizer ignore-deprecated */ $this->packIntoBox($box, $items);
Loading history...
304
305
        return $packedBox->getItems();
306
    }
307
308
    /**
309
     * Given a solution set of packed boxes, repack them to achieve optimum weight distribution.
310
     *
311
     * @deprecated
312
     *
313
     * @param PackedBoxList $originalBoxes
314
     *
315
     * @return PackedBoxList
316
     */
317
    public function redistributeWeight(PackedBoxList $originalBoxes)
318
    {
319
        $redistributor = new WeightRedistributor($this->boxes);
320
        $redistributor->setLogger($this->logger);
321
322
        return $redistributor->redistributeWeight($originalBoxes);
323
    }
324
}
325