Completed
Push — master ( 1d342b...59040c )
by Doug
19:50
created

OrientatedItemFactory   A

Complexity

Total Complexity 36

Size/Duplication

Total Lines 346
Duplicated Lines 0 %

Test Coverage

Coverage 64.96%

Importance

Changes 14
Bugs 3 Features 0
Metric Value
eloc 131
c 14
b 3
f 0
dl 0
loc 346
ccs 89
cts 137
cp 0.6496
rs 9.52
wmc 36

8 Methods

Rating   Name   Duplication   Size   Complexity  
C getBestOrientation() 0 65 12
A __construct() 0 4 1
B getPossibleOrientations() 0 60 8
A getStableOrientationsInEmptyBox() 0 8 1
A calculateAdditionalItemsPackedWithThisOrientation() 0 35 3
A isSameDimensions() 0 8 1
B getUsableOrientations() 0 32 7
A getPossibleOrientationsInEmptyBox() 0 34 3
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 = $orientationsDimensions = [];
157
158 22
        $isSame = false;
159 22
        if ($prevItem) {
160 20
            $itemADimensions = [$item->getWidth(), $item->getLength(), $item->getDepth()];
161 20
            $itemBDimensions = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
162 20
            sort($itemADimensions);
163 20
            sort($itemBDimensions);
164 20
            $isSame = ($itemADimensions === $itemBDimensions);
165
        }
166
167
        //Special case items that are the same as what we just packed - keep orientation
168 22
        if ($isSame) {
169 16
            $orientationsDimensions[] = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
1 ignored issue
show
Bug introduced by
The method getWidth() does not exist on null. ( Ignorable by Annotation )

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

169
            $orientationsDimensions[] = [$prevItem->/** @scrutinizer ignore-call */ getWidth(), $prevItem->getLength(), $prevItem->getDepth()];

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
170
        } else {
171
            //simple 2D rotation
172 22
            $orientationsDimensions[] = [$item->getWidth(), $item->getLength(), $item->getDepth()];
173 22
            $orientationsDimensions[] = [$item->getLength(), $item->getWidth(), $item->getDepth()];
174
175
            //add 3D rotation if we're allowed
176 22
            if (!$item->getKeepFlat()) {
177 14
                $orientationsDimensions[] = [$item->getWidth(), $item->getDepth(), $item->getLength()];
178 14
                $orientationsDimensions[] = [$item->getLength(), $item->getDepth(), $item->getWidth()];
179 14
                $orientationsDimensions[] = [$item->getDepth(), $item->getWidth(), $item->getLength()];
180 14
                $orientationsDimensions[] = [$item->getDepth(), $item->getLength(), $item->getWidth()];
181
            }
182
        }
183
184
        //remove any that simply don't fit
185 22
        $orientationsDimensions = array_unique($orientationsDimensions, SORT_REGULAR);
186
        $orientationsDimensions = array_filter($orientationsDimensions, static function (array $i) use ($widthLeft, $lengthLeft, $depthLeft) {
187 22
            return $i[0] <= $widthLeft && $i[1] <= $lengthLeft && $i[2] <= $depthLeft;
188 22
        });
189
190 22
        foreach ($orientationsDimensions as $dimensions) {
191 21
            $orientations[] = new OrientatedItem($item, $dimensions[0], $dimensions[1], $dimensions[2]);
192
        };
193
194 22
        if ($item instanceof ConstrainedPlacementItem) {
195
            $box = $this->box;
196
            $orientations = array_filter($orientations, static function (OrientatedItem $i) use ($box, $x, $y, $z, $prevPackedItemList) {
197
                /** @var ConstrainedPlacementItem $constrainedItem */
198
                $constrainedItem = $i->getItem();
199
200
                return $constrainedItem->canBePacked($box, $prevPackedItemList, $x, $y, $z, $i->getWidth(), $i->getLength(), $i->getDepth());
201
            });
202
        }
203
204 22
        return $orientations;
205
    }
206
207
    /**
208
     * @param  Item             $item
209
     * @return OrientatedItem[]
210
     */
211
    public function getPossibleOrientationsInEmptyBox(Item $item): array
212
    {
213
        $cacheKey = $item->getWidth() .
214
            '|' .
215
            $item->getLength() .
216
            '|' .
217
            $item->getDepth() .
218
            '|' .
219
            ($item->getKeepFlat() ? '2D' : '3D') .
220
            '|' .
221
            $this->box->getInnerWidth() .
222
            '|' .
223
            $this->box->getInnerLength() .
224
            '|' .
225
            $this->box->getInnerDepth();
226
227
        if (isset(static::$emptyBoxCache[$cacheKey])) {
228
            $orientations = static::$emptyBoxCache[$cacheKey];
229
        } else {
230
            $orientations = $this->getPossibleOrientations(
231
                $item,
232
                null,
233
                $this->box->getInnerWidth(),
234
                $this->box->getInnerLength(),
235
                $this->box->getInnerDepth(),
236
                0,
237
                0,
238
                0,
239
                new PackedItemList()
240
            );
241
            static::$emptyBoxCache[$cacheKey] = $orientations;
242
        }
243
244
        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...
245
    }
246
247
    /**
248
     * @param Item             $item
249
     * @param OrientatedItem[] $possibleOrientations
250
     * @param bool             $isLastItem
251
     *
252
     * @return OrientatedItem[]
253
     */
254 22
    protected function getUsableOrientations(
255
        Item $item,
256
        $possibleOrientations,
257
        bool $isLastItem
258
    ): array {
259 22
        $orientationsToUse = $stableOrientations = $unstableOrientations = [];
260
261
        // Divide possible orientations into stable (low centre of gravity) and unstable (high centre of gravity)
262 22
        foreach ($possibleOrientations as $orientation) {
263 21
            if ($orientation->isStable()) {
264 21
                $stableOrientations[] = $orientation;
265
            } else {
266 21
                $unstableOrientations[] = $orientation;
267
            }
268
        }
269
270
        /*
271
         * We prefer to use stable orientations only, but allow unstable ones if either
272
         * the item is the last one left to pack OR
273
         * the item doesn't fit in the box any other way
274
         */
275 22
        if (count($stableOrientations) > 0) {
276 21
            $orientationsToUse = $stableOrientations;
277 21
        } elseif (count($unstableOrientations) > 0) {
278
            $stableOrientationsInEmptyBox = $this->getStableOrientationsInEmptyBox($item);
279
280
            if ($isLastItem || count($stableOrientationsInEmptyBox) === 0) {
281
                $orientationsToUse = $unstableOrientations;
282
            }
283
        }
284
285 22
        return $orientationsToUse;
286
    }
287
288
    /**
289
     * Return the orientations for this item if it were to be placed into the box with nothing else.
290
     *
291
     * @param  Item  $item
292
     * @return array
293
     */
294
    protected function getStableOrientationsInEmptyBox(Item $item): array
295
    {
296
        $orientationsInEmptyBox = $this->getPossibleOrientationsInEmptyBox($item);
297
298
        return array_filter(
299
            $orientationsInEmptyBox,
300
            function (OrientatedItem $orientation) {
301
                return $orientation->isStable();
302
            }
303
        );
304
    }
305
306
    /**
307
     * Compare two items to see if they have same dimensions.
308
     *
309
     * @param Item $itemA
310
     * @param Item $itemB
311
     *
312
     * @return bool
313
     */
314
    protected function isSameDimensions(Item $itemA, Item $itemB): bool
315
    {
316
        $itemADimensions = [$itemA->getWidth(), $itemA->getLength(), $itemA->getDepth()];
317
        $itemBDimensions = [$itemB->getWidth(), $itemB->getLength(), $itemB->getDepth()];
318
        sort($itemADimensions);
319
        sort($itemBDimensions);
320
321
        return $itemADimensions === $itemBDimensions;
322
    }
323
324
    /**
325
     * Approximation of a forward-looking packing.
326
     *
327
     * Not an actual packing, that has additional logic regarding constraints and stackability, this focuses
328
     * purely on fit.
329
     *
330
     * @param  OrientatedItem $prevItem
331
     * @param  ItemList       $nextItems
332
     * @param  int            $originalWidthLeft
333
     * @param  int            $originalLengthLeft
334
     * @param  int            $depthLeft
335
     * @param  int            $currentRowLengthBeforePacking
336
     * @return int
337
     */
338 2
    protected function calculateAdditionalItemsPackedWithThisOrientation(
339
        OrientatedItem $prevItem,
340
        ItemList $nextItems,
341
        int $originalWidthLeft,
342
        int $originalLengthLeft,
343
        int $depthLeft,
344
        int $currentRowLengthBeforePacking
345
    ): int {
346 2
        $packedCount = 0;
347
348 2
        $currentRowLength = max($prevItem->getLength(), $currentRowLengthBeforePacking);
349
350 2
        $itemsToPack = $nextItems->topN(8); // cap lookahead as this gets recursive and slow
351
352 2
        $tempBox = new WorkingVolume($originalWidthLeft - $prevItem->getWidth(), $currentRowLength, $depthLeft, PHP_INT_MAX);
353 2
        $tempPacker = new VolumePacker($tempBox, clone $itemsToPack);
354 2
        $tempPacker->setLookAheadMode(true);
355 2
        $remainingRowPacked = $tempPacker->pack();
356
        /** @var PackedItem $packedItem */
357 2
        foreach ($remainingRowPacked->getItems() as $packedItem) {
358 2
            $itemsToPack->remove($packedItem->getItem());
359
        }
360
361 2
        $tempBox = new WorkingVolume($originalWidthLeft, $originalLengthLeft - $currentRowLength, $depthLeft, PHP_INT_MAX);
362 2
        $tempPacker = new VolumePacker($tempBox, clone $itemsToPack);
363 2
        $tempPacker->setLookAheadMode(true);
364 2
        $nextRowsPacked = $tempPacker->pack();
365
        /** @var PackedItem $packedItem */
366 2
        foreach ($nextRowsPacked->getItems() as $packedItem) {
367 1
            $itemsToPack->remove($packedItem->getItem());
368
        }
369
370 2
        $this->logger->debug('Lookahead with orientation', ['packedCount' => $packedCount, 'orientatedItem' => $prevItem]);
371
372 2
        return $nextItems->count() - $itemsToPack->count();
373
    }
374
}
375