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