Passed
Push — 3.x ( 313aa6...013a94 )
by Doug
01:46
created

Packer::setLogger()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 3
ccs 0
cts 0
cp 0
crap 2
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 Psr\Log\LoggerAwareInterface;
12
use Psr\Log\LoggerInterface;
13
use Psr\Log\LogLevel;
14
use Psr\Log\NullLogger;
15
use SplObjectStorage;
16
17
use function count;
18
use function usort;
19
20
use const PHP_INT_MAX;
21
22
/**
23
 * Actual packer.
24
 */
25
class Packer implements LoggerAwareInterface
26
{
27
    private LoggerInterface $logger;
28
29
    protected int $maxBoxesToBalanceWeight = 12;
30
31
    protected ItemList $items;
32
33
    protected BoxList $boxes;
34
35
    /**
36
     * @var SplObjectStorage<Box, int>
37
     */
38
    protected SplObjectStorage $boxQuantitiesAvailable;
39
40
    protected PackedBoxSorter $packedBoxSorter;
41
42
    private bool $beStrictAboutItemOrdering = false;
43
44
    public function __construct()
45
    {
46
        $this->items = new ItemList();
47
        $this->boxes = new BoxList();
48
        $this->boxQuantitiesAvailable = new SplObjectStorage();
49
        $this->packedBoxSorter = new DefaultPackedBoxSorter();
50
51
        $this->logger = new NullLogger();
52
    }
53
54
    public function setLogger(LoggerInterface $logger): void
55
    {
56
        $this->logger = $logger;
57
    }
58
59
    /**
60
     * Add item to be packed.
61
     */
62
    public function addItem(Item $item, int $qty = 1): void
63
    {
64
        $this->items->insert($item, $qty);
65
        $this->logger->log(LogLevel::INFO, "added {$qty} x {$item->getDescription()}", ['item' => $item]);
66
    }
67
68
    /**
69
     * Set a list of items all at once.
70 41
     * @param iterable<Item> $items
71
     */
72 41
    public function setItems(iterable $items): void
73 41
    {
74 41
        if ($items instanceof ItemList) {
75 41
            $this->items = clone $items;
76
        } else {
77 41
            $this->items = new ItemList();
78
            foreach ($items as $item) {
79
                $this->items->insert($item);
80
            }
81
        }
82
    }
83 26
84
    /**
85 26
     * Add box size.
86 26
     */
87
    public function addBox(Box $box): void
88
    {
89
        $this->boxes->insert($box);
90
        $this->setBoxQuantity($box, $box instanceof LimitedSupplyBox ? $box->getQuantityAvailable() : PHP_INT_MAX);
91
        $this->logger->log(LogLevel::INFO, "added box {$box->getReference()}", ['box' => $box]);
92
    }
93 17
94
    /**
95 17
     * Add a pre-prepared set of boxes all at once.
96 14
     */
97
    public function setBoxes(BoxList $boxList): void
98 4
    {
99 4
        $this->boxes = $boxList;
100 4
        foreach ($this->boxes as $box) {
101
            $this->setBoxQuantity($box, $box instanceof LimitedSupplyBox ? $box->getQuantityAvailable() : PHP_INT_MAX);
102
        }
103
    }
104
105
    /**
106
     * Set the quantity of this box type available.
107
     */
108 27
    public function setBoxQuantity(Box $box, int $qty): void
109
    {
110 27
        $this->boxQuantitiesAvailable[$box] = $qty;
111 27
    }
112 27
113
    /**
114
     * Number of boxes at which balancing weight is deemed not worth the extra computation time.
115
     */
116
    public function getMaxBoxesToBalanceWeight(): int
117
    {
118 16
        return $this->maxBoxesToBalanceWeight;
119
    }
120 16
121 16
    /**
122 16
     * Number of boxes at which balancing weight is deemed not worth the extra computation time.
123
     */
124
    public function setMaxBoxesToBalanceWeight(int $maxBoxesToBalanceWeight): void
125
    {
126
        $this->maxBoxesToBalanceWeight = $maxBoxesToBalanceWeight;
127
    }
128
129 39
    public function setPackedBoxSorter(PackedBoxSorter $packedBoxSorter): void
130
    {
131 39
        $this->packedBoxSorter = $packedBoxSorter;
132
    }
133
134
    public function beStrictAboutItemOrdering(bool $beStrict): void
135
    {
136
        $this->beStrictAboutItemOrdering = $beStrict;
137 1
    }
138
139 1
    /**
140
     * Pack items into boxes using built-in heuristics for the best solution.
141
     */
142
    public function pack(): PackedBoxList
143
    {
144
        $this->logger->log(LogLevel::INFO, '[PACKING STARTED]');
145 4
146
        $packedBoxes = $this->doBasicPacking();
147 4
148
        // If we have multiple boxes, try and optimise/even-out weight distribution
149
        if (!$this->beStrictAboutItemOrdering && $packedBoxes->count() > 1 && $packedBoxes->count() <= $this->maxBoxesToBalanceWeight) {
150 1
            $redistributor = new WeightRedistributor($this->boxes, $this->packedBoxSorter, $this->boxQuantitiesAvailable);
151
            $redistributor->setLogger($this->logger);
152 1
            $packedBoxes = $redistributor->redistributeWeight($packedBoxes);
153
        }
154
155 1
        $this->logger->log(LogLevel::INFO, "[PACKING COMPLETED], {$packedBoxes->count()} boxes");
156
157 1
        return $packedBoxes;
158
    }
159
160
    /**
161
     * @internal
162
     */
163 40
    public function doBasicPacking(bool $enforceSingleBox = false): PackedBoxList
164
    {
165 40
        $packedBoxes = new PackedBoxList($this->packedBoxSorter);
166
167
        // Keep going until everything packed
168 37
        while ($this->items->count()) {
169 12
            $packedBoxesIteration = [];
170 12
171 12
            // Loop through boxes starting with smallest, see what happens
172
            foreach ($this->getBoxList($enforceSingleBox) as $box) {
173
                $volumePacker = new VolumePacker($box, $this->items);
0 ignored issues
show
Bug introduced by
$box of type array is incompatible with the type DVDoug\BoxPacker\Box expected by parameter $box of DVDoug\BoxPacker\VolumePacker::__construct(). ( Ignorable by Annotation )

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

173
                $volumePacker = new VolumePacker(/** @scrutinizer ignore-type */ $box, $this->items);
Loading history...
174 37
                $volumePacker->setLogger($this->logger);
175
                $volumePacker->beStrictAboutItemOrdering($this->beStrictAboutItemOrdering);
176 37
                $packedBox = $volumePacker->pack();
177
                if ($packedBox->getItems()->count()) {
178
                    $packedBoxesIteration[] = $packedBox;
179
180
                    // Have we found a single box that contains everything?
181
                    if ($packedBox->getItems()->count() === $this->items->count()) {
182
                        $this->logger->log(LogLevel::DEBUG, "Single box found for remaining {$this->items->count()} items");
183
                        break;
184 40
                    }
185
                }
186 40
            }
187
188
            if (count($packedBoxesIteration) > 0) {
189 40
                // Find best box of iteration, and remove packed items from unpacked list
190 39
                usort($packedBoxesIteration, [$this->packedBoxSorter, 'compare']);
191
                $bestBox = $packedBoxesIteration[0];
192
193 39
                $this->items->removePackedItems($bestBox->getItems());
194 38
195 38
                $packedBoxes->insert($bestBox);
196 38
                $this->boxQuantitiesAvailable[$bestBox->getBox()] = $this->boxQuantitiesAvailable[$bestBox->getBox()] - 1;
197 38
            } elseif (!$enforceSingleBox) {
198 38
                throw new NoBoxesAvailableException("No boxes could be found for item '{$this->items->top()->getDescription()}'", $this->items->top());
199 38
            } else {
200 37
                $this->logger->log(LogLevel::INFO, "{$this->items->count()} unpackable items found");
201
                break;
202
            }
203 37
        }
204 35
205
        return $packedBoxes;
206
    }
207
208
    /**
209
     * Get a "smart" ordering of the boxes to try packing items into. The initial BoxList is already sorted in order
210
     * so that the smallest boxes are evaluated first, but this means that time is spent on boxes that cannot possibly
211 39
     * hold the entire set of items due to volume limitations. These should be evaluated first.
212 5
     *
213 5
     * @return iterable<Box>
214 1
     */
215
    protected function getBoxList(bool $enforceSingleBox = false): iterable
216 4
    {
217
        $this->logger->log(LogLevel::INFO, 'Determining box search pattern', ['enforceSingleBox' => $enforceSingleBox]);
218
        $itemVolume = 0;
219 37
        foreach ($this->items as $item) {
220
            $itemVolume += $item->getWidth() * $item->getLength() * $item->getDepth();
221 37
        }
222 37
        $this->logger->log(LogLevel::DEBUG, 'Item volume', ['itemVolume' => $itemVolume]);
223
224
        $preferredBoxes = [];
225 37
        $otherBoxes = [];
226
        foreach ($this->boxes as $box) {
227
            if ($this->boxQuantitiesAvailable[$box] > 0) {
228
                if ($box->getInnerWidth() * $box->getInnerLength() * $box->getInnerDepth() >= $itemVolume) {
229
                    $preferredBoxes[] = $box;
230
                } elseif (!$enforceSingleBox) {
231
                    $otherBoxes[] = $box;
232
                }
233 39
            }
234
        }
235 39
236 39
        $this->logger->log(LogLevel::INFO, 'Box search pattern complete', ['preferredBoxCount' => count($preferredBoxes), 'otherBoxCount' => count($otherBoxes)]);
237 39
238
        return [...$preferredBoxes, ...$otherBoxes];
239
    }
240
}
241