Passed
Push — master ( 816a9c...aa6a1b )
by Doug
03:36
created

calculateAdditionalItemsPackedWithThisOrientation()   B

Complexity

Conditions 7
Paths 9

Size

Total Lines 40
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 24
CRAP Score 7

Importance

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