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

OrientatedItemFactory   A

Complexity

Total Complexity 39

Size/Duplication

Total Lines 340
Duplicated Lines 0 %

Test Coverage

Coverage 89.39%

Importance

Changes 0
Metric Value
eloc 127
dl 0
loc 340
ccs 118
cts 132
cp 0.8939
rs 9.28
c 0
b 0
f 0
wmc 39

8 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 3 1
C getBestOrientation() 0 64 12
B getPossibleOrientations() 0 46 7
A getStableOrientationsInEmptyBox() 0 8 1
B calculateAdditionalItemsPackedWithThisOrientation() 0 44 7
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 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