Completed
Push — master ( 57e6b1...4bc4cb )
by Doug
09:08
created

OrientatedItemFactory::getBestOrientation()   C

Complexity

Conditions 12
Paths 2

Size

Total Lines 65
Code Lines 32

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 33
CRAP Score 12

Importance

Changes 9
Bugs 3 Features 0
Metric Value
cc 12
eloc 32
c 9
b 3
f 0
nc 2
nop 12
dl 0
loc 65
ccs 33
cts 33
cp 1
crap 12
rs 6.9666

How to fix   Long Method    Complexity    Many Parameters   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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_filter;
12
use function count;
13
use function min;
14
use Psr\Log\LoggerAwareInterface;
15
use Psr\Log\LoggerAwareTrait;
16
use Psr\Log\NullLogger;
17
use function reset;
18
use function sort;
19
use function usort;
20
21
/**
22
 * Figure out orientations for an item and a given set of dimensions.
23
 *
24
 * @author Doug Wright
25
 * @internal
26
 */
27
class OrientatedItemFactory implements LoggerAwareInterface
28
{
29
    use LoggerAwareTrait;
30
31
    /** @var Box */
32
    protected $box;
33
34
    /**
35
     * @var OrientatedItem[]
36
     */
37
    protected static $emptyBoxCache = [];
38
39
    /**
40
     * @var int[]
41
     */
42
    protected static $lookaheadCache = [];
43
44 51
    public function __construct(Box $box)
45
    {
46 51
        $this->box = $box;
47 51
        $this->logger = new NullLogger();
48 51
    }
49
50
    /**
51
     * Get the best orientation for an item.
52
     */
53 49
    public function getBestOrientation(
54
        Item $item,
55
        ?OrientatedItem $prevItem,
56
        ItemList $nextItems,
57
        bool $isLastItem,
58
        int $widthLeft,
59
        int $lengthLeft,
60
        int $depthLeft,
61
        int $rowLength,
62
        int $x,
63
        int $y,
64
        int $z,
65
        PackedItemList $prevPackedItemList
66
    ): ?OrientatedItem {
67 49
        $possibleOrientations = $this->getPossibleOrientations($item, $prevItem, $widthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
68 49
        $usableOrientations = $this->getUsableOrientations($item, $possibleOrientations, $isLastItem);
69
70 49
        if (empty($usableOrientations)) {
71 47
            return null;
72
        }
73
74
        usort($usableOrientations, function (OrientatedItem $a, OrientatedItem $b) use ($widthLeft, $lengthLeft, $depthLeft, $nextItems, $rowLength, $x, $y, $z, $prevPackedItemList) {
75 28
            $orientationAWidthLeft = $widthLeft - $a->getWidth();
76 28
            $orientationALengthLeft = $lengthLeft - $a->getLength();
77 28
            $orientationADepthLeft = $depthLeft - $a->getDepth();
78 28
            $orientationBWidthLeft = $widthLeft - $b->getWidth();
79 28
            $orientationBLengthLeft = $lengthLeft - $b->getLength();
80 28
            $orientationBDepthLeft = $depthLeft - $b->getDepth();
81
82 28
            $orientationAMinGap = min($orientationAWidthLeft, $orientationALengthLeft);
83 28
            $orientationBMinGap = min($orientationBWidthLeft, $orientationBLengthLeft);
84
85 28
            if ($orientationAMinGap === 0) { // prefer A if it leaves no gap
86 15
                return -1;
87
            }
88 25
            if ($orientationBMinGap === 0) { // prefer B if it leaves no gap
89 16
                return 1;
90
            }
91
92
            // prefer leaving room for next item in current row
93 24
            if ($nextItems->count()) {
94 21
                $nextItemFitA = count($this->getPossibleOrientations($nextItems->top(), $a, $orientationAWidthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList));
95 21
                $nextItemFitB = count($this->getPossibleOrientations($nextItems->top(), $b, $orientationBWidthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList));
96 21
                if ($nextItemFitA && !$nextItemFitB) {
97 9
                    return -1;
98
                }
99 19
                if ($nextItemFitB && !$nextItemFitA) {
100 8
                    return 1;
101
                }
102
103
                // if not an easy either/or, do a partial lookahead
104 17
                $additionalPackedA = $this->calculateAdditionalItemsPackedWithThisOrientation($a, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
105 17
                $additionalPackedB = $this->calculateAdditionalItemsPackedWithThisOrientation($b, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
106 17
                if ($additionalPackedA !== $additionalPackedB) {
107 8
                    return $additionalPackedB <=> $additionalPackedA;
108
                }
109
            }
110
            // otherwise prefer leaving minimum possible gap, or the greatest footprint
111 20
            return $orientationADepthLeft <=> $orientationBDepthLeft ?: $orientationAMinGap <=> $orientationBMinGap ?: $a->getSurfaceFootprint() <=> $b->getSurfaceFootprint();
112 49
        });
113
114 49
        $bestFit = reset($usableOrientations);
115 49
        $this->logger->debug('Selected best fit orientation', ['orientation' => $bestFit]);
116
117 49
        return $bestFit;
118
    }
119
120
    /**
121
     * Find all possible orientations for an item.
122
     *
123
     * @return OrientatedItem[]
124
     */
125 50
    public function getPossibleOrientations(
126
        Item $item,
127
        ?OrientatedItem $prevItem,
128
        int $widthLeft,
129
        int $lengthLeft,
130
        int $depthLeft,
131
        int $x,
132
        int $y,
133
        int $z,
134
        PackedItemList $prevPackedItemList
135
    ): array {
136 50
        $orientations = $orientationsDimensions = [];
137
138 50
        $isSame = false;
139 50
        if ($prevItem) {
140 47
            $itemADimensions = [$item->getWidth(), $item->getLength(), $item->getDepth()];
141 47
            $itemBDimensions = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
142 47
            sort($itemADimensions);
143 47
            sort($itemBDimensions);
144 47
            $isSame = ($itemADimensions === $itemBDimensions);
145
        }
146
147
        //Special case items that are the same as what we just packed - keep orientation
148 50
        if ($isSame && $prevItem) {
149 40
            $orientationsDimensions[] = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
150
        } else {
151
            //simple 2D rotation
152 50
            $orientationsDimensions[] = [$item->getWidth(), $item->getLength(), $item->getDepth()];
153 50
            $orientationsDimensions[] = [$item->getLength(), $item->getWidth(), $item->getDepth()];
154
155
            //add 3D rotation if we're allowed
156 50
            if (!$item->getKeepFlat()) {
157 31
                $orientationsDimensions[] = [$item->getWidth(), $item->getDepth(), $item->getLength()];
158 31
                $orientationsDimensions[] = [$item->getLength(), $item->getDepth(), $item->getWidth()];
159 31
                $orientationsDimensions[] = [$item->getDepth(), $item->getWidth(), $item->getLength()];
160 31
                $orientationsDimensions[] = [$item->getDepth(), $item->getLength(), $item->getWidth()];
161
            }
162
        }
163
164
        //remove any that simply don't fit
165 50
        $orientationsDimensions = array_unique($orientationsDimensions, SORT_REGULAR);
166
        $orientationsDimensions = array_filter($orientationsDimensions, static function (array $i) use ($widthLeft, $lengthLeft, $depthLeft) {
167 50
            return $i[0] <= $widthLeft && $i[1] <= $lengthLeft && $i[2] <= $depthLeft;
168 50
        });
169
170 50
        foreach ($orientationsDimensions as $dimensions) {
171 49
            $orientations[] = new OrientatedItem($item, $dimensions[0], $dimensions[1], $dimensions[2]);
172
        }
173
174 50
        if ($item instanceof ConstrainedPlacementItem) {
175 2
            $box = $this->box;
176
            $orientations = array_filter($orientations, static function (OrientatedItem $i) use ($box, $x, $y, $z, $prevPackedItemList) {
177
                /** @var ConstrainedPlacementItem $constrainedItem */
178 2
                $constrainedItem = $i->getItem();
179
180 2
                return $constrainedItem->canBePacked($box, $prevPackedItemList, $x, $y, $z, $i->getWidth(), $i->getLength(), $i->getDepth());
181 2
            });
182
        }
183
184 50
        return $orientations;
185
    }
186
187
    /**
188
     * @return OrientatedItem[]
189
     */
190 26
    public function getPossibleOrientationsInEmptyBox(Item $item): array
191
    {
192 26
        $cacheKey = $item->getWidth() .
193 26
            '|' .
194 26
            $item->getLength() .
195 26
            '|' .
196 26
            $item->getDepth() .
197 26
            '|' .
198 26
            ($item->getKeepFlat() ? '2D' : '3D') .
199 26
            '|' .
200 26
            $this->box->getInnerWidth() .
201 26
            '|' .
202 26
            $this->box->getInnerLength() .
203 26
            '|' .
204 26
            $this->box->getInnerDepth();
205
206 26
        if (isset(static::$emptyBoxCache[$cacheKey])) {
207 21
            $orientations = static::$emptyBoxCache[$cacheKey];
208
        } else {
209 24
            $orientations = $this->getPossibleOrientations(
210 24
                $item,
211 24
                null,
212 24
                $this->box->getInnerWidth(),
213 24
                $this->box->getInnerLength(),
214 24
                $this->box->getInnerDepth(),
215 24
                0,
216 24
                0,
217 24
                0,
218 24
                new PackedItemList()
219
            );
220 24
            static::$emptyBoxCache[$cacheKey] = $orientations;
221
        }
222
223 26
        return $orientations;
1 ignored issue
show
Bug Best Practice introduced by
The expression return $orientations could return the type DVDoug\BoxPacker\OrientatedItem which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
224
    }
225
226
    /**
227
     * @param OrientatedItem[] $possibleOrientations
228
     *
229
     * @return OrientatedItem[]
230
     */
231 49
    protected function getUsableOrientations(
232
        Item $item,
233
        $possibleOrientations,
234
        bool $isLastItem
235
    ): array {
236 49
        $orientationsToUse = $stableOrientations = $unstableOrientations = [];
237
238
        // Divide possible orientations into stable (low centre of gravity) and unstable (high centre of gravity)
239 49
        foreach ($possibleOrientations as $orientation) {
240 49
            if ($orientation->isStable()) {
241 47
                $stableOrientations[] = $orientation;
242
            } else {
243 49
                $unstableOrientations[] = $orientation;
244
            }
245
        }
246
247
        /*
248
         * We prefer to use stable orientations only, but allow unstable ones if either
249
         * the item is the last one left to pack OR
250
         * the item doesn't fit in the box any other way
251
         */
252 49
        if (count($stableOrientations) > 0) {
253 47
            $orientationsToUse = $stableOrientations;
254 47
        } elseif (count($unstableOrientations) > 0) {
255 6
            $stableOrientationsInEmptyBox = $this->getStableOrientationsInEmptyBox($item);
256
257 6
            if ($isLastItem || count($stableOrientationsInEmptyBox) === 0) {
258 4
                $orientationsToUse = $unstableOrientations;
259
            }
260
        }
261
262 49
        return $orientationsToUse;
263
    }
264
265
    /**
266
     * Return the orientations for this item if it were to be placed into the box with nothing else.
267
     */
268 6
    protected function getStableOrientationsInEmptyBox(Item $item): array
269
    {
270 6
        $orientationsInEmptyBox = $this->getPossibleOrientationsInEmptyBox($item);
271
272 6
        return array_filter(
273 6
            $orientationsInEmptyBox,
274
            function (OrientatedItem $orientation) {
275 6
                return $orientation->isStable();
276 6
            }
277
        );
278
    }
279
280
    /**
281
     * Approximation of a forward-looking packing.
282
     *
283
     * Not an actual packing, that has additional logic regarding constraints and stackability, this focuses
284
     * purely on fit.
285
     */
286 17
    protected function calculateAdditionalItemsPackedWithThisOrientation(
287
        OrientatedItem $prevItem,
288
        ItemList $nextItems,
289
        int $originalWidthLeft,
290
        int $originalLengthLeft,
291
        int $depthLeft,
292
        int $currentRowLengthBeforePacking
293
    ): int {
294 17
        $packedCount = 0;
295
296 17
        $currentRowLength = max($prevItem->getLength(), $currentRowLengthBeforePacking);
297
298 17
        $itemsToPack = $nextItems->topN(8); // cap lookahead as this gets recursive and slow
299
300
        $cacheKey = $originalWidthLeft .
301 17
            '|' .
302 17
            $originalLengthLeft .
303 17
            '|' .
304 17
            $prevItem->getWidth() .
305 17
            '|' .
306 17
            $prevItem->getLength() .
307 17
            '|' .
308 17
            $currentRowLength .
309 17
            '|'
310 17
            . $depthLeft;
311
312
        /** @var Item $itemToPack */
313 17
        foreach ($itemsToPack as $itemToPack) {
314
            $cacheKey .= '|' .
315 17
                $itemToPack->getWidth() .
316 17
                '|' .
317 17
                $itemToPack->getLength() .
318 17
                '|' .
319 17
                $itemToPack->getDepth() .
320 17
                '|' .
321 17
                $itemToPack->getWeight() .
322 17
                '|' .
323 17
                ($itemToPack->getKeepFlat() ? '1' : '0');
324
        }
325
326 17
        if (!isset(static::$lookaheadCache[$cacheKey])) {
327 17
            $tempBox = new WorkingVolume($originalWidthLeft - $prevItem->getWidth(), $currentRowLength, $depthLeft, PHP_INT_MAX);
328 17
            $tempPacker = new VolumePacker($tempBox, clone $itemsToPack);
329 17
            $tempPacker->setLookAheadMode(true);
330 17
            $remainingRowPacked = $tempPacker->pack();
331
            /** @var PackedItem $packedItem */
332 17
            foreach ($remainingRowPacked->getItems() as $packedItem) {
333 17
                $itemsToPack->remove($packedItem->getItem());
334
            }
335
336 17
            $tempBox = new WorkingVolume($originalWidthLeft, $originalLengthLeft - $currentRowLength, $depthLeft, PHP_INT_MAX);
337 17
            $tempPacker = new VolumePacker($tempBox, clone $itemsToPack);
338 17
            $tempPacker->setLookAheadMode(true);
339 17
            $nextRowsPacked = $tempPacker->pack();
340
            /** @var PackedItem $packedItem */
341 17
            foreach ($nextRowsPacked->getItems() as $packedItem) {
342 13
                $itemsToPack->remove($packedItem->getItem());
343
            }
344
345 17
            $this->logger->debug('Lookahead with orientation', ['packedCount' => $packedCount, 'orientatedItem' => $prevItem]);
346
347 17
            static::$lookaheadCache[$cacheKey] = $nextItems->count() - $itemsToPack->count();
348
        }
349
350 17
        return static::$lookaheadCache[$cacheKey];
351
    }
352
}
353