Passed
Push — limited_supply_boxes ( 50c059...126eb4 )
by Doug
09:09
created

OrientatedItemFactory::getPossibleOrientations()   B

Complexity

Conditions 9
Paths 24

Size

Total Lines 60
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 29
CRAP Score 9

Importance

Changes 4
Bugs 1 Features 0
Metric Value
cc 9
eloc 29
c 4
b 1
f 0
nc 24
nop 9
dl 0
loc 60
ccs 29
cts 29
cp 1
crap 9
rs 8.0555

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