Completed
Push — master ( 67bebb...73147e )
by Doug
35:04
created

OrientatedItemFactory::getBestOrientation()   C

Complexity

Conditions 12
Paths 2

Size

Total Lines 65
Code Lines 32

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 32
CRAP Score 12.004

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