Passed
Push — issue_156 ( a97a78 )
by Doug
03:35
created

OrientatedItemFactory::getPossibleOrientations()   B

Complexity

Conditions 7
Paths 6

Size

Total Lines 46
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 7.457

Importance

Changes 0
Metric Value
cc 7
eloc 19
nc 6
nop 9
dl 0
loc 46
ccs 15
cts 19
cp 0.7895
crap 7.457
rs 8.8333
c 0
b 0
f 0

How to fix   Many Parameters   

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