Completed
Push — refactor_layerpacker ( dfc43c...84dfe0 )
by Doug
07:56
created

OrientatedItemFactory::getPossibleOrientations()   C

Complexity

Conditions 14
Paths 8

Size

Total Lines 50
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 14

Importance

Changes 7
Bugs 1 Features 0
Metric Value
cc 14
eloc 22
c 7
b 1
f 0
nc 8
nop 9
dl 0
loc 50
ccs 21
cts 21
cp 1
crap 14
rs 6.2666

How to fix   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 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
        bool $singlePassMode
76
    ): ?OrientatedItem {
77 56
        $this->logger->debug(
78 56
            "evaluating item {$item->getDescription()} for fit",
79
            [
80 56
                'item' => $item,
81
                'space' => [
82 56
                    'widthLeft' => $widthLeft,
83 56
                    'lengthLeft' => $lengthLeft,
84 56
                    'depthLeft' => $depthLeft,
85
                ],
86
            ]
87
        );
88
89 56
        $possibleOrientations = $this->getPossibleOrientations($item, $prevItem, $widthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
90 56
        $usableOrientations = $this->getUsableOrientations($item, $possibleOrientations);
91
92 56
        if (empty($usableOrientations)) {
93 50
            return null;
94
        }
95
96
        usort($usableOrientations, function (OrientatedItem $a, OrientatedItem $b) use ($widthLeft, $lengthLeft, $depthLeft, $nextItems, $rowLength, $x, $y, $z, $prevPackedItemList, $singlePassMode) {
0 ignored issues
show
Unused Code introduced by
The import $singlePassMode is not used and could be removed.

This check looks for imports that have been defined, but are not used in the scope.

Loading history...
97
            //Prefer exact fits in width/length/depth order
98 52
            $orientationAWidthLeft = $widthLeft - $a->getWidth();
99 52
            $orientationBWidthLeft = $widthLeft - $b->getWidth();
100 52
            if ($orientationAWidthLeft === 0 && $orientationBWidthLeft > 0) {
101 7
                return -1;
102
            }
103 50
            if ($orientationAWidthLeft > 0 && $orientationBWidthLeft === 0) {
104 5
                return 1;
105
            }
106
107 50
            $orientationALengthLeft = $lengthLeft - $a->getLength();
108 50
            $orientationBLengthLeft = $lengthLeft - $b->getLength();
109 50
            if ($orientationALengthLeft === 0 && $orientationBLengthLeft > 0) {
110 14
                return -1;
111
            }
112 50
            if ($orientationALengthLeft > 0 && $orientationBLengthLeft === 0) {
113 15
                return 1;
114
            }
115
116 50
            $orientationADepthLeft = $depthLeft - $a->getDepth();
117 50
            $orientationBDepthLeft = $depthLeft - $b->getDepth();
118 50
            if ($orientationADepthLeft === 0 && $orientationBDepthLeft > 0) {
119 4
                return -1;
120
            }
121 50
            if ($orientationADepthLeft > 0 && $orientationBDepthLeft === 0) {
122 2
                return 1;
123
            }
124
125
            // prefer leaving room for next item in current row
126 49
            if ($nextItems->count()) {
127 46
                $nextItemFitA = $this->getPossibleOrientations($nextItems->top(), $a, $orientationAWidthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
128 46
                $nextItemFitB = $this->getPossibleOrientations($nextItems->top(), $b, $orientationBWidthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
129 46
                if ($nextItemFitA && !$nextItemFitB) {
130 9
                    return -1;
131
                }
132 46
                if ($nextItemFitB && !$nextItemFitA) {
133 12
                    return 1;
134
                }
135
136
                // if not an easy either/or, do a partial lookahead
137 44
                $additionalPackedA = $this->calculateAdditionalItemsPackedWithThisOrientation($a, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
138 44
                $additionalPackedB = $this->calculateAdditionalItemsPackedWithThisOrientation($b, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
139 44
                if ($additionalPackedA !== $additionalPackedB) {
140 9
                    return $additionalPackedB <=> $additionalPackedA;
141
                }
142
            }
143 45
            $orientationAMinGap = min($orientationAWidthLeft, $orientationALengthLeft);
144 45
            $orientationBMinGap = min($orientationBWidthLeft, $orientationBLengthLeft);
145
            // otherwise prefer leaving minimum possible gap, or the greatest footprint
146 45
            return $orientationAMinGap <=> $orientationBMinGap ?: $a->getSurfaceFootprint() <=> $b->getSurfaceFootprint();
147 56
        });
148
149 56
        $this->logger->debug('Selected best fit orientation', ['orientation' => $usableOrientations[0]]);
150
151 56
        return $usableOrientations[0];
152
    }
153
154
    /**
155
     * Find all possible orientations for an item.
156
     *
157
     * @return OrientatedItem[]
158
     */
159 57
    public function getPossibleOrientations(
160
        Item $item,
161
        ?OrientatedItem $prevItem,
162
        int $widthLeft,
163
        int $lengthLeft,
164
        int $depthLeft,
165
        int $x,
166
        int $y,
167
        int $z,
168
        PackedItemList $prevPackedItemList
169
    ): array {
170 57
        $orientations = $orientationsDimensions = [];
171
172
        //Special case items that are the same as what we just packed - keep orientation
173 57
        if ($prevItem && $item === $prevItem->getItem() && $prevItem->getWidth() <= $widthLeft && $prevItem->getLength() <= $lengthLeft && $prevItem->getDepth() <= $depthLeft) {
174 23
            $orientations[] = $prevItem; // reuse the existing object for a small speed boost
175
        } else {
176
            //Might be different a item but having same dimensions - apply same rule
177 57
            if ($prevItem && $prevItem->isSameDimensions($item)) {
178 41
                $orientationsDimensions[] = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
179
            } else {
180
                //simple 2D rotation
181 57
                $orientationsDimensions[] = [$item->getWidth(), $item->getLength(), $item->getDepth()];
182 57
                $orientationsDimensions[] = [$item->getLength(), $item->getWidth(), $item->getDepth()];
183
184
                //add 3D rotation if we're allowed
185 57
                if (!$item->getKeepFlat()) {
186 39
                    $orientationsDimensions[] = [$item->getWidth(), $item->getDepth(), $item->getLength()];
187 39
                    $orientationsDimensions[] = [$item->getLength(), $item->getDepth(), $item->getWidth()];
188 39
                    $orientationsDimensions[] = [$item->getDepth(), $item->getWidth(), $item->getLength()];
189 39
                    $orientationsDimensions[] = [$item->getDepth(), $item->getLength(), $item->getWidth()];
190
                }
191
            }
192
193
            //remove any that simply don't fit
194 57
            foreach ($orientationsDimensions as $dimensions) {
195 57
                if ($dimensions[0] <= $widthLeft && $dimensions[1] <= $lengthLeft && $dimensions[2] <= $depthLeft) {
196 56
                    $orientations[] = new OrientatedItem($item, $dimensions[0], $dimensions[1], $dimensions[2]);
197
                }
198
            }
199
        }
200
201 57
        if ($item instanceof ConstrainedPlacementItem) {
202 2
            $box = $this->box;
203
            $orientations = array_filter($orientations, static function (OrientatedItem $i) use ($box, $x, $y, $z, $prevPackedItemList) {
204 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

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