Passed
Push — master ( ce89c2...6da5d1 )
by Doug
05:15
created

OrientatedItemFactory::getBestOrientation()   A

Complexity

Conditions 5
Paths 2

Size

Total Lines 55
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 5

Importance

Changes 15
Bugs 3 Features 0
Metric Value
cc 5
eloc 23
c 15
b 3
f 0
nc 2
nop 11
dl 0
loc 55
ccs 23
cts 23
cp 1
crap 5
rs 9.2408

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 usort;
18
19
/**
20
 * Figure out orientations for an item and a given set of dimensions.
21
 *
22
 * @author Doug Wright
23
 * @internal
24
 */
25
class OrientatedItemFactory implements LoggerAwareInterface
26
{
27
    use LoggerAwareTrait;
28
29
    /** @var Box */
30
    protected $box;
31
32
    /**
33
     * Whether the packer is in single-pass mode.
34
     *
35
     * @var bool
36
     */
37
    protected $singlePassMode = false;
38
39
    /**
40
     * @var OrientatedItem[]
41
     */
42
    protected static $emptyBoxCache = [];
43
44
    /**
45
     * @var int[]
46
     */
47
    protected static $lookaheadCache = [];
48
49 58
    public function __construct(Box $box)
50
    {
51 58
        $this->box = $box;
52 58
        $this->logger = new NullLogger();
53 58
    }
54
55 51
    public function setSinglePassMode(bool $singlePassMode): void
56
    {
57 51
        $this->singlePassMode = $singlePassMode;
58 51
    }
59
60
    /**
61
     * Get the best orientation for an item.
62
     */
63 56
    public function getBestOrientation(
64
        Item $item,
65
        ?OrientatedItem $prevItem,
66
        ItemList $nextItems,
67
        int $widthLeft,
68
        int $lengthLeft,
69
        int $depthLeft,
70
        int $rowLength,
71
        int $x,
72
        int $y,
73
        int $z,
74
        PackedItemList $prevPackedItemList
75
    ): ?OrientatedItem {
76 56
        $this->logger->debug(
77 56
            "evaluating item {$item->getDescription()} for fit",
78
            [
79 56
                'item' => $item,
80
                'space' => [
81 56
                    'widthLeft' => $widthLeft,
82 56
                    'lengthLeft' => $lengthLeft,
83 56
                    'depthLeft' => $depthLeft,
84
                ],
85
            ]
86
        );
87
88 56
        $possibleOrientations = $this->getPossibleOrientations($item, $prevItem, $widthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
89 56
        $usableOrientations = $this->getUsableOrientations($item, $possibleOrientations);
90
91 56
        if (empty($usableOrientations)) {
92 50
            return null;
93
        }
94
95
        usort($usableOrientations, function (OrientatedItem $a, OrientatedItem $b) use ($widthLeft, $lengthLeft, $depthLeft, $nextItems, $rowLength, $x, $y, $z, $prevPackedItemList) {
96
            //Prefer exact fits in width/length/depth order
97 52
            $exactFitDecider = $this->exactFitDecider($a, $b, $widthLeft, $lengthLeft, $depthLeft);
98 52
            if ($exactFitDecider !== 0) {
99 24
                return $exactFitDecider;
100
            }
101
102
            // prefer leaving room for next item(s)
103 49
            $followingItemDecider = $this->lookAheadDecider($nextItems, $a, $b, $widthLeft, $lengthLeft, $depthLeft, $rowLength, $x, $y, $z, $prevPackedItemList);
104 49
            if ($followingItemDecider !== 0) {
105 18
                return $followingItemDecider;
106
            }
107
108
            // otherwise prefer leaving minimum possible gap, or the greatest footprint
109 45
            $orientationAMinGap = min($widthLeft - $a->getWidth(), $lengthLeft - $a->getLength());
110 45
            $orientationBMinGap = min($widthLeft - $b->getWidth(), $lengthLeft - $b->getLength());
111
112 45
            return $orientationAMinGap <=> $orientationBMinGap ?: $a->getSurfaceFootprint() <=> $b->getSurfaceFootprint();
113 56
        });
114
115 56
        $this->logger->debug('Selected best fit orientation', ['orientation' => $usableOrientations[0]]);
116
117 56
        return $usableOrientations[0];
118
    }
119
120
    /**
121
     * Find all possible orientations for an item.
122
     *
123
     * @return OrientatedItem[]
124
     */
125 57
    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 57
        $orientations = $orientationsDimensions = [];
137
138
        //Special case items that are the same as what we just packed - keep orientation
139 57
        if ($prevItem && $item === $prevItem->getItem() && $prevItem->getWidth() <= $widthLeft && $prevItem->getLength() <= $lengthLeft && $prevItem->getDepth() <= $depthLeft) {
140 23
            $orientations[] = $prevItem; // reuse the existing object for a small speed boost
141
        } else {
142
            //Might be different a item but having same dimensions - apply same rule
143 57
            if ($prevItem && $prevItem->isSameDimensions($item)) {
144 42
                $orientationsDimensions[] = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
145
            } else {
146
                //simple 2D rotation
147 57
                $orientationsDimensions[] = [$item->getWidth(), $item->getLength(), $item->getDepth()];
148 57
                $orientationsDimensions[] = [$item->getLength(), $item->getWidth(), $item->getDepth()];
149
150
                //add 3D rotation if we're allowed
151 57
                if (!$item->getKeepFlat()) {
152 39
                    $orientationsDimensions[] = [$item->getWidth(), $item->getDepth(), $item->getLength()];
153 39
                    $orientationsDimensions[] = [$item->getLength(), $item->getDepth(), $item->getWidth()];
154 39
                    $orientationsDimensions[] = [$item->getDepth(), $item->getWidth(), $item->getLength()];
155 39
                    $orientationsDimensions[] = [$item->getDepth(), $item->getLength(), $item->getWidth()];
156
                }
157
            }
158
159
            //remove any that simply don't fit
160 57
            foreach ($orientationsDimensions as $dimensions) {
161 57
                if ($dimensions[0] <= $widthLeft && $dimensions[1] <= $lengthLeft && $dimensions[2] <= $depthLeft) {
162 56
                    $orientations[] = new OrientatedItem($item, $dimensions[0], $dimensions[1], $dimensions[2]);
163
                }
164
            }
165
        }
166
167 57
        if ($item instanceof ConstrainedPlacementItem) {
168 2
            $box = $this->box;
169
            $orientations = array_filter($orientations, static function (OrientatedItem $i) use ($box, $x, $y, $z, $prevPackedItemList) {
170 2
                return $i->getItem()->canBePacked($box, $prevPackedItemList, $x, $y, $z, $i->getWidth(), $i->getLength(), $i->getDepth());
1 ignored issue
show
Bug introduced by
The method canBePacked() does not exist on DVDoug\BoxPacker\Item. It seems like you code against a sub-type of DVDoug\BoxPacker\Item such as DVDoug\BoxPacker\ConstrainedPlacementItem or DVDoug\BoxPacker\Test\Co...lacementByCountTestItem or DVDoug\BoxPacker\Test\Co...ementNoStackingTestItem. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

170
                return $i->getItem()->/** @scrutinizer ignore-call */ canBePacked($box, $prevPackedItemList, $x, $y, $z, $i->getWidth(), $i->getLength(), $i->getDepth());
Loading history...
171 2
            });
172
        }
173
174 57
        return $orientations;
175
    }
176
177
    /**
178
     * @return OrientatedItem[]
179
     */
180 32
    public function getPossibleOrientationsInEmptyBox(Item $item): array
181
    {
182 32
        $cacheKey = $item->getWidth() .
183 32
            '|' .
184 32
            $item->getLength() .
185 32
            '|' .
186 32
            $item->getDepth() .
187 32
            '|' .
188 32
            ($item->getKeepFlat() ? '2D' : '3D') .
189 32
            '|' .
190 32
            $this->box->getInnerWidth() .
191 32
            '|' .
192 32
            $this->box->getInnerLength() .
193 32
            '|' .
194 32
            $this->box->getInnerDepth();
195
196 32
        if (isset(static::$emptyBoxCache[$cacheKey])) {
197 26
            $orientations = static::$emptyBoxCache[$cacheKey];
198
        } else {
199 28
            $orientations = $this->getPossibleOrientations(
200 28
                $item,
201 28
                null,
202 28
                $this->box->getInnerWidth(),
203 28
                $this->box->getInnerLength(),
204 28
                $this->box->getInnerDepth(),
205 28
                0,
206 28
                0,
207 28
                0,
208 28
                new PackedItemList()
209
            );
210 28
            static::$emptyBoxCache[$cacheKey] = $orientations;
211
        }
212
213 32
        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...
214
    }
215
216
    /**
217
     * @param OrientatedItem[] $possibleOrientations
218
     *
219
     * @return OrientatedItem[]
220
     */
221 56
    protected function getUsableOrientations(
222
        Item $item,
223
        array $possibleOrientations
224
    ): array {
225 56
        $orientationsToUse = $stableOrientations = $unstableOrientations = [];
226
227
        // Divide possible orientations into stable (low centre of gravity) and unstable (high centre of gravity)
228 56
        foreach ($possibleOrientations as $orientation) {
229 56
            if ($orientation->isStable() || $this->box->getInnerDepth() === $orientation->getDepth()) {
230 54
                $stableOrientations[] = $orientation;
231
            } else {
232 9
                $unstableOrientations[] = $orientation;
233
            }
234
        }
235
236
        /*
237
         * We prefer to use stable orientations only, but allow unstable ones if
238
         * the item doesn't fit in the box any other way
239
         */
240 56
        if (count($stableOrientations) > 0) {
241 54
            $orientationsToUse = $stableOrientations;
242 50
        } elseif (count($unstableOrientations) > 0) {
243 9
            $stableOrientationsInEmptyBox = $this->getStableOrientationsInEmptyBox($item);
244
245 9
            if (count($stableOrientationsInEmptyBox) === 0) {
246 6
                $orientationsToUse = $unstableOrientations;
247
            }
248
        }
249
250 56
        return $orientationsToUse;
251
    }
252
253
    /**
254
     * Return the orientations for this item if it were to be placed into the box with nothing else.
255
     */
256 9
    protected function getStableOrientationsInEmptyBox(Item $item): array
257
    {
258 9
        $orientationsInEmptyBox = $this->getPossibleOrientationsInEmptyBox($item);
259
260 9
        return array_filter(
261 9
            $orientationsInEmptyBox,
262
            function (OrientatedItem $orientation) {
263 9
                return $orientation->isStable();
264 9
            }
265
        );
266
    }
267
268
    /**
269
     * Approximation of a forward-looking packing.
270
     *
271
     * Not an actual packing, that has additional logic regarding constraints and stackability, this focuses
272
     * purely on fit.
273
     */
274 44
    protected function calculateAdditionalItemsPackedWithThisOrientation(
275
        OrientatedItem $prevItem,
276
        ItemList $nextItems,
277
        int $originalWidthLeft,
278
        int $originalLengthLeft,
279
        int $depthLeft,
280
        int $currentRowLengthBeforePacking
281
    ): int {
282 44
        if ($this->singlePassMode) {
283 20
            return 0;
284
        }
285
286 44
        $currentRowLength = max($prevItem->getLength(), $currentRowLengthBeforePacking);
287
288 44
        $itemsToPack = $nextItems->topN(8); // cap lookahead as this gets recursive and slow
289
290
        $cacheKey = $originalWidthLeft .
291 44
            '|' .
292 44
            $originalLengthLeft .
293 44
            '|' .
294 44
            $prevItem->getWidth() .
295 44
            '|' .
296 44
            $prevItem->getLength() .
297 44
            '|' .
298 44
            $currentRowLength .
299 44
            '|'
300 44
            . $depthLeft;
301
302
        /** @var Item $itemToPack */
303 44
        foreach ($itemsToPack as $itemToPack) {
304
            $cacheKey .= '|' .
305 44
                $itemToPack->getWidth() .
306 44
                '|' .
307 44
                $itemToPack->getLength() .
308 44
                '|' .
309 44
                $itemToPack->getDepth() .
310 44
                '|' .
311 44
                $itemToPack->getWeight() .
312 44
                '|' .
313 44
                ($itemToPack->getKeepFlat() ? '1' : '0');
314
        }
315
316 44
        if (!isset(static::$lookaheadCache[$cacheKey])) {
317 41
            $tempBox = new WorkingVolume($originalWidthLeft - $prevItem->getWidth(), $currentRowLength, $depthLeft, PHP_INT_MAX);
318 41
            $tempPacker = new VolumePacker($tempBox, $itemsToPack);
319 41
            $tempPacker->setSinglePassMode(true);
320 41
            $remainingRowPacked = $tempPacker->pack();
321
            /** @var PackedItem $packedItem */
322 41
            foreach ($remainingRowPacked->getItems() as $packedItem) {
323 27
                $itemsToPack->remove($packedItem->getItem());
324
            }
325
326 41
            $tempBox = new WorkingVolume($originalWidthLeft, $originalLengthLeft - $currentRowLength, $depthLeft, PHP_INT_MAX);
327 41
            $tempPacker = new VolumePacker($tempBox, $itemsToPack);
328 41
            $tempPacker->setSinglePassMode(true);
329 41
            $nextRowsPacked = $tempPacker->pack();
330
            /** @var PackedItem $packedItem */
331 41
            foreach ($nextRowsPacked->getItems() as $packedItem) {
332 23
                $itemsToPack->remove($packedItem->getItem());
333
            }
334
335 41
            $packedCount = $nextItems->count() - $itemsToPack->count();
336 41
            $this->logger->debug('Lookahead with orientation', ['packedCount' => $packedCount, 'orientatedItem' => $prevItem]);
337
338 41
            static::$lookaheadCache[$cacheKey] = $packedCount;
339
        }
340
341 44
        return static::$lookaheadCache[$cacheKey];
342
    }
343
344 52
    private function exactFitDecider(OrientatedItem $a, OrientatedItem $b, int $widthLeft, int $lengthLeft, int $depthLeft): int
345
    {
346 52
        $orientationAWidthLeft = $widthLeft - $a->getWidth();
347 52
        $orientationBWidthLeft = $widthLeft - $b->getWidth();
348 52
        if ($orientationAWidthLeft === 0 && $orientationBWidthLeft > 0) {
349 7
            return -1;
350
        }
351 50
        if ($orientationBWidthLeft === 0 && $orientationAWidthLeft > 0) {
352 5
            return 1;
353
        }
354
355 50
        $orientationALengthLeft = $lengthLeft - $a->getLength();
356 50
        $orientationBLengthLeft = $lengthLeft - $b->getLength();
357 50
        if ($orientationALengthLeft === 0 && $orientationBLengthLeft > 0) {
358 14
            return -1;
359
        }
360 50
        if ($orientationBLengthLeft === 0 && $orientationALengthLeft > 0) {
361 15
            return 1;
362
        }
363
364 50
        $orientationADepthLeft = $depthLeft - $a->getDepth();
365 50
        $orientationBDepthLeft = $depthLeft - $b->getDepth();
366 50
        if ($orientationADepthLeft === 0 && $orientationBDepthLeft > 0) {
367 4
            return -1;
368
        }
369 50
        if ($orientationBDepthLeft === 0 && $orientationADepthLeft > 0) {
370 2
            return 1;
371
        }
372
373 49
        return 0;
374
    }
375
376 49
    private function lookAheadDecider(ItemList $nextItems, OrientatedItem $a, OrientatedItem $b, int $widthLeft, int $lengthLeft, int $depthLeft, int $rowLength, int $x, int $y, int $z, PackedItemList $prevPackedItemList): int
377
    {
378 49
        if ($nextItems->count() === 0) {
379 32
            return 0;
380
        }
381
382 46
        $nextItemFitA = $this->getPossibleOrientations($nextItems->top(), $a, $widthLeft - $a->getWidth(), $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
383 46
        $nextItemFitB = $this->getPossibleOrientations($nextItems->top(), $b, $widthLeft - $b->getWidth(), $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
384 46
        if ($nextItemFitA && !$nextItemFitB) {
385 9
            return -1;
386
        }
387 46
        if ($nextItemFitB && !$nextItemFitA) {
388 12
            return 1;
389
        }
390
391
        // if not an easy either/or, do a partial lookahead
392 44
        $additionalPackedA = $this->calculateAdditionalItemsPackedWithThisOrientation($a, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
393 44
        $additionalPackedB = $this->calculateAdditionalItemsPackedWithThisOrientation($b, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
394
395 44
        return $additionalPackedB <=> $additionalPackedA ?: 0;
396
    }
397
}
398