Passed
Push — 2.x-dev ( e9f8a3...78c6d6 )
by Doug
04:07
created

OrientatedItemFactory   A

Complexity

Total Complexity 37

Size/Duplication

Total Lines 333
Duplicated Lines 0 %

Test Coverage

Coverage 88.98%

Importance

Changes 7
Bugs 3 Features 0
Metric Value
wmc 37
eloc 122
c 7
b 3
f 0
dl 0
loc 333
ccs 113
cts 127
cp 0.8898
rs 9.44

8 Methods

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