Passed
Push — master ( 0bad10...5719ef )
by Doug
02:22 queued 10s
created

Packer::addBox()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

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