Passed
Pull Request — master (#335)
by
unknown
05:51 queued 04:02
created

VolumePacker   A

Complexity

Total Complexity 30

Size/Duplication

Total Lines 225
Duplicated Lines 0 %

Test Coverage

Coverage 87.64%

Importance

Changes 60
Bugs 2 Features 0
Metric Value
eloc 89
c 60
b 2
f 0
dl 0
loc 225
ccs 78
cts 89
cp 0.8764
rs 10
wmc 30

11 Methods

Rating   Name   Duplication   Size   Complexity  
A setSinglePassMode() 0 7 2
B packRotation() 0 40 6
A beStrictAboutItemOrdering() 0 4 1
A getPackedItemList() 0 10 3
A __construct() 0 12 1
A getCurrentPackedDepth() 0 8 2
A pack() 0 30 6
A setLogger() 0 4 1
A correctLayerRotation() 0 17 4
A packAcrossWidthOnly() 0 3 1
A stabiliseLayers() 0 9 3
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\NullLogger;
14
15
use function array_map;
16
use function count;
17
use function max;
18
use function reset;
19
use function usort;
20
21
/**
22
 * Actual packer.
23
 */
24
class VolumePacker implements LoggerAwareInterface
25
{
26
    protected LoggerInterface $logger;
27
28
    protected Box $box;
29
30
    protected ItemList $items;
31
32
    protected bool $singlePassMode = false;
33
34
    protected bool $packAcrossWidthOnly = false;
35
36
    private LayerPacker $layerPacker;
37
38
    protected bool $beStrictAboutItemOrdering = false;
39
40
    private bool $hasConstrainedItems = false;
41
42
    private bool $hasNoRotationItems = false;
43
44 84
    public function __construct(Box $box, ItemList $items)
45
    {
46 84
        $this->box = $box;
47 84
        $this->items = clone $items;
48
49 84
        $this->logger = new NullLogger();
50
51 84
        $this->hasConstrainedItems = $items->hasConstrainedItems();
52 84
        $this->hasNoRotationItems = $items->hasNoRotationItems();
53
54 84
        $this->layerPacker = new LayerPacker($this->box);
55 84
        $this->layerPacker->setLogger($this->logger);
56
    }
57
58
    /**
59
     * Sets a logger.
60
     */
61 24
    public function setLogger(LoggerInterface $logger): void
62
    {
63 24
        $this->logger = $logger;
64 24
        $this->layerPacker->setLogger($logger);
65
    }
66
67
    public function packAcrossWidthOnly(): void
68
    {
69
        $this->packAcrossWidthOnly = true;
70
    }
71
72 24
    public function beStrictAboutItemOrdering(bool $beStrict): void
73
    {
74 24
        $this->beStrictAboutItemOrdering = $beStrict;
75 24
        $this->layerPacker->beStrictAboutItemOrdering($beStrict);
76
    }
77
78
    /**
79
     * @internal
80
     */
81 4
    public function setSinglePassMode(bool $singlePassMode): void
82
    {
83 4
        $this->singlePassMode = $singlePassMode;
84 4
        if ($singlePassMode) {
85 4
            $this->packAcrossWidthOnly = true;
86
        }
87 4
        $this->layerPacker->setSinglePassMode($singlePassMode);
88
    }
89
90
    /**
91
     * Pack as many items as possible into specific given box.
92
     *
93
     * @return PackedBox packed box
94
     */
95 84
    public function pack(): PackedBox
96
    {
97 84
        $this->logger->debug("[EVALUATING BOX] {$this->box->getReference()}", ['box' => $this->box]);
98
99 84
        $rotationsToTest = [false];
100 84
        if (!$this->packAcrossWidthOnly && !$this->hasNoRotationItems) {
101 84
            $rotationsToTest[] = true;
102
        }
103
104 84
        $boxPermutations = [];
105 84
        foreach ($rotationsToTest as $rotation) {
106 84
            if ($rotation) {
107 16
                $boxWidth = $this->box->getInnerLength();
108 16
                $boxLength = $this->box->getInnerWidth();
109
            } else {
110 84
                $boxWidth = $this->box->getInnerWidth();
111 84
                $boxLength = $this->box->getInnerLength();
112
            }
113
114 84
            $boxPermutation = $this->packRotation($boxWidth, $boxLength);
115 84
            if ($boxPermutation->getItems()->count() === $this->items->count()) {
116 72
                return $boxPermutation;
117
            }
118
119 18
            $boxPermutations[] = $boxPermutation;
120
        }
121
122 18
        usort($boxPermutations, static fn (PackedBox $a, PackedBox $b) => $b->getVolumeUtilisation() <=> $a->getVolumeUtilisation());
123
124 18
        return reset($boxPermutations);
125
    }
126
127
    /**
128
     * Pack as many items as possible into specific given box.
129
     *
130
     * @return PackedBox packed box
131
     */
132 84
    private function packRotation(int $boxWidth, int $boxLength): PackedBox
133
    {
134 84
        $this->logger->debug("[EVALUATING ROTATION] {$this->box->getReference()}", ['width' => $boxWidth, 'length' => $boxLength]);
135
136 84
        $layers = [];
137 84
        $items = clone $this->items;
138
139 84
        while ($items->count() > 0) {
140 84
            $layerStartDepth = self::getCurrentPackedDepth($layers);
141 84
            $packedItemList = $this->getPackedItemList($layers);
142
143
            // do a preliminary layer pack to get the depth used
144 84
            $preliminaryItems = clone $items;
145 84
            $preliminaryLayer = $this->layerPacker->packLayer($preliminaryItems, clone $packedItemList, 0, 0, $layerStartDepth, $boxWidth, $boxLength, $this->box->getInnerDepth() - $layerStartDepth, 0, true);
146 84
            if (count($preliminaryLayer->getItems()) === 0) {
147 14
                break;
148
            }
149
150 82
            if ($preliminaryLayer->getDepth() === $preliminaryLayer->getItems()[0]->getDepth()) { // preliminary === final
151 80
                $layers[] = $preliminaryLayer;
152 80
                $items = $preliminaryItems;
153
            } else { // redo with now-known-depth so that we can stack to that height from the first item
154 4
                $layers[] = $this->layerPacker->packLayer($items, $packedItemList, 0, 0, $layerStartDepth, $boxWidth, $boxLength, $this->box->getInnerDepth() - $layerStartDepth, $preliminaryLayer->getDepth(), true);
155
            }
156
        }
157
158 84
        if (!$this->singlePassMode && $layers) {
159 82
            $layers = $this->stabiliseLayers($layers);
160
161
            // having packed layers, there may be tall, narrow gaps at the ends that can be utilised
162 82
            $maxLayerWidth = max(array_map(static fn (PackedLayer $layer) => $layer->getEndX(), $layers));
163 82
            $layers[] = $this->layerPacker->packLayer($items, $this->getPackedItemList($layers), $maxLayerWidth, 0, 0, $boxWidth, $boxLength, $this->box->getInnerDepth(), $this->box->getInnerDepth(), false);
164
165 82
            $maxLayerLength = max(array_map(static fn (PackedLayer $layer) => $layer->getEndY(), $layers));
166 82
            $layers[] = $this->layerPacker->packLayer($items, $this->getPackedItemList($layers), 0, $maxLayerLength, 0, $boxWidth, $boxLength, $this->box->getInnerDepth(), $this->box->getInnerDepth(), false);
167
        }
168
169 84
        $layers = $this->correctLayerRotation($layers, $boxWidth);
170
171 84
        return new PackedBox($this->box, $this->getPackedItemList($layers));
172
    }
173
174
    /**
175
     * During packing, it is quite possible that layers have been created that aren't physically stable
176
     * i.e. they overhang the ones below.
177
     *
178
     * This function reorders them so that the ones with the greatest surface area are placed at the bottom
179
     *
180
     * @param  PackedLayer[] $oldLayers
181
     * @return PackedLayer[]
182
     */
183 82
    private function stabiliseLayers(array $oldLayers): array
184
    {
185 82
        if ($this->hasConstrainedItems || $this->beStrictAboutItemOrdering) { // constraints include position, so cannot change
186
            return $oldLayers;
187
        }
188
189 82
        $stabiliser = new LayerStabiliser();
190
191 82
        return $stabiliser->stabilise($oldLayers);
192
    }
193
194
    /**
195
     * Swap back width/length of the packed items to match orientation of the box if needed.
196
     *
197
     * @param PackedLayer[] $oldLayers
198
     *
199
     * @return PackedLayer[]
200
     */
201 84
    private function correctLayerRotation(array $oldLayers, int $boxWidth): array
202
    {
203 84
        if ($this->box->getInnerWidth() === $boxWidth) {
204 84
            return $oldLayers;
205
        }
206
207
        $newLayers = [];
208
        foreach ($oldLayers as $originalLayer) {
209
            $newLayer = new PackedLayer();
210
            foreach ($originalLayer->getItems() as $item) {
211
                $packedItem = new PackedItem($item->getItem(), $item->getY(), $item->getX(), $item->getZ(), $item->getLength(), $item->getWidth(), $item->getDepth());
212
                $newLayer->insert($packedItem);
213
            }
214
            $newLayers[] = $newLayer;
215
        }
216
217
        return $newLayers;
218
    }
219
220
    /**
221
     * Generate a single list of items packed.
222
     * @param PackedLayer[] $layers
223
     */
224 84
    private function getPackedItemList(array $layers): PackedItemList
225
    {
226 84
        $packedItemList = new PackedItemList();
227 84
        foreach ($layers as $layer) {
228 82
            foreach ($layer->getItems() as $packedItem) {
229 82
                $packedItemList->insert($packedItem);
230
            }
231
        }
232
233 84
        return $packedItemList;
234
    }
235
236
    /**
237
     * Return the current packed depth.
238
     *
239
     * @param PackedLayer[] $layers
240
     */
241 84
    private static function getCurrentPackedDepth(array $layers): int
242
    {
243 84
        $depth = 0;
244 84
        foreach ($layers as $layer) {
245 54
            $depth += $layer->getDepth();
246
        }
247
248 84
        return $depth;
249
    }
250
}
251