Completed
Push — test_jit ( c34f6e...87859e )
by Doug
10:03
created

OrientatedItemFactory::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 4
ccs 3
cts 3
cp 1
crap 1
rs 10
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 function array_filter;
12
use function count;
13
use function min;
14
use Psr\Log\LoggerAwareInterface;
15
use Psr\Log\LoggerAwareTrait;
16
use Psr\Log\NullLogger;
17
use function reset;
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
    /**
39
     * @var int[]
40
     */
41
    protected static $lookaheadCache = [];
42
43 58
    public function __construct(Box $box)
44
    {
45 58
        $this->box = $box;
46 58
        $this->logger = new NullLogger();
47 58
    }
48
49
    /**
50
     * Get the best orientation for an item.
51
     */
52 56
    public function getBestOrientation(
53
        Item $item,
54
        ?OrientatedItem $prevItem,
55
        ItemList $nextItems,
56
        int $widthLeft,
57
        int $lengthLeft,
58
        int $depthLeft,
59
        int $rowLength,
60
        int $x,
61
        int $y,
62
        int $z,
63
        PackedItemList $prevPackedItemList,
64
        bool $singlePassMode
65
    ): ?OrientatedItem {
66 56
        $this->logger->debug(
67 56
            "evaluating item {$item->getDescription()} for fit",
68
            [
69 56
                'item' => $item,
70
                'space' => [
71 56
                    'widthLeft' => $widthLeft,
72 56
                    'lengthLeft' => $lengthLeft,
73 56
                    'depthLeft' => $depthLeft,
74
                ],
75
            ]
76
        );
77
78 56
        $possibleOrientations = $this->getPossibleOrientations($item, $prevItem, $widthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
79 56
        $usableOrientations = $this->getUsableOrientations($item, $possibleOrientations);
80
81 56
        if (empty($usableOrientations)) {
82 50
            return null;
83
        }
84
85
        usort($usableOrientations, function (OrientatedItem $a, OrientatedItem $b) use ($widthLeft, $lengthLeft, $depthLeft, $nextItems, $rowLength, $x, $y, $z, $prevPackedItemList, $singlePassMode) {
86
            //Prefer exact fits in width/length/depth order
87 52
            $orientationAWidthLeft = $widthLeft - $a->getWidth();
88 52
            $orientationBWidthLeft = $widthLeft - $b->getWidth();
89 52
            if ($orientationAWidthLeft === 0 && $orientationBWidthLeft > 0) {
90 7
                return -1;
91
            }
92 50
            if ($orientationAWidthLeft > 0 && $orientationBWidthLeft === 0) {
93 5
                return 1;
94
            }
95
96 50
            $orientationALengthLeft = $lengthLeft - $a->getLength();
97 50
            $orientationBLengthLeft = $lengthLeft - $b->getLength();
98 50
            if ($orientationALengthLeft === 0 && $orientationBLengthLeft > 0) {
99 14
                return -1;
100
            }
101 50
            if ($orientationALengthLeft > 0 && $orientationBLengthLeft === 0) {
102 15
                return 1;
103
            }
104
105 50
            $orientationADepthLeft = $depthLeft - $a->getDepth();
106 50
            $orientationBDepthLeft = $depthLeft - $b->getDepth();
107 50
            if ($orientationADepthLeft === 0 && $orientationBDepthLeft > 0) {
108 4
                return -1;
109
            }
110 50
            if ($orientationADepthLeft > 0 && $orientationBDepthLeft === 0) {
111 2
                return 1;
112
            }
113
114 49
            $orientationAMinGap = min($orientationAWidthLeft, $orientationALengthLeft);
115 49
            $orientationBMinGap = min($orientationBWidthLeft, $orientationBLengthLeft);
116 49
            if ($orientationAMinGap === 0 && $orientationBMinGap === 0) {
117 27
                return $b->getDepth() <=> $a->getDepth();
118
            }
119
120
            // prefer leaving room for next item in current row
121 41
            if ($nextItems->count()) {
122 38
                $nextItemFitA = $this->getPossibleOrientations($nextItems->top(), $a, $orientationAWidthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
123 38
                $nextItemFitB = $this->getPossibleOrientations($nextItems->top(), $b, $orientationBWidthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
124 38
                if ($nextItemFitA && !$nextItemFitB) {
125 9
                    return -1;
126
                }
127 38
                if ($nextItemFitB && !$nextItemFitA) {
128 12
                    return 1;
129
                }
130
131 35
                if (!$singlePassMode) {
132
                    // if not an easy either/or, do a partial lookahead
133 35
                    $additionalPackedA = $this->calculateAdditionalItemsPackedWithThisOrientation($a, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
134 35
                    $additionalPackedB = $this->calculateAdditionalItemsPackedWithThisOrientation($b, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
135 35
                    if ($additionalPackedA !== $additionalPackedB) {
136 9
                        return $additionalPackedB <=> $additionalPackedA;
137
                    }
138
                }
139
            }
140
            // otherwise prefer leaving minimum possible gap, or the greatest footprint
141 36
            return $orientationAMinGap <=> $orientationBMinGap ?: $a->getSurfaceFootprint() <=> $b->getSurfaceFootprint();
142 56
        });
143
144 56
        $bestFit = reset($usableOrientations);
145 56
        $this->logger->debug('Selected best fit orientation', ['orientation' => $bestFit]);
146
147 56
        return $bestFit;
148
    }
149
150
    /**
151
     * Find all possible orientations for an item.
152
     *
153
     * @return OrientatedItem[]
154
     */
155 57
    public function getPossibleOrientations(
156
        Item $item,
157
        ?OrientatedItem $prevItem,
158
        int $widthLeft,
159
        int $lengthLeft,
160
        int $depthLeft,
161
        int $x,
162
        int $y,
163
        int $z,
164
        PackedItemList $prevPackedItemList
165
    ): array {
166 57
        $orientations = $orientationsDimensions = [];
167
168
        //Special case items that are the same as what we just packed - keep orientation
169 57
        if ($prevItem && $item === $prevItem->getItem() && $prevItem->getWidth() <= $widthLeft && $prevItem->getLength() <= $lengthLeft && $prevItem->getDepth() <= $depthLeft) {
170 22
            $orientations[] = $prevItem; // reuse the existing object for a small speed boost
171
        } else {
172
            //Might be different a item but having same dimensions - apply same rule
173 57
            if ($prevItem && $prevItem->isSameDimensions($item)) {
174 39
                $orientationsDimensions[] = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
175
            } else {
176
                //simple 2D rotation
177 57
                $orientationsDimensions[] = [$item->getWidth(), $item->getLength(), $item->getDepth()];
178 57
                $orientationsDimensions[] = [$item->getLength(), $item->getWidth(), $item->getDepth()];
179
180
                //add 3D rotation if we're allowed
181 57
                if (!$item->getKeepFlat()) {
182 39
                    $orientationsDimensions[] = [$item->getWidth(), $item->getDepth(), $item->getLength()];
183 39
                    $orientationsDimensions[] = [$item->getLength(), $item->getDepth(), $item->getWidth()];
184 39
                    $orientationsDimensions[] = [$item->getDepth(), $item->getWidth(), $item->getLength()];
185 39
                    $orientationsDimensions[] = [$item->getDepth(), $item->getLength(), $item->getWidth()];
186
                }
187
            }
188
189
            //remove any that simply don't fit
190 57
            foreach ($orientationsDimensions as $dimensions) {
191 57
                if ($dimensions[0] <= $widthLeft && $dimensions[1] <= $lengthLeft && $dimensions[2] <= $depthLeft) {
192 56
                    $orientations[] = new OrientatedItem($item, $dimensions[0], $dimensions[1], $dimensions[2]);
193
                }
194
            }
195
        }
196
197 57
        if ($item instanceof ConstrainedPlacementItem) {
198 2
            $box = $this->box;
199
            $orientations = array_filter($orientations, static function (OrientatedItem $i) use ($box, $x, $y, $z, $prevPackedItemList) {
200 2
                return $i->getItem()->canBePacked($box, $prevPackedItemList, $x, $y, $z, $i->getWidth(), $i->getLength(), $i->getDepth());
1 ignored issue
show
Bug introduced by
The method canBePacked() does not exist on DVDoug\BoxPacker\Item. It seems like you code against a sub-type of DVDoug\BoxPacker\Item such as DVDoug\BoxPacker\ConstrainedPlacementItem or DVDoug\BoxPacker\Test\Co...lacementByCountTestItem or DVDoug\BoxPacker\Test\Co...ementNoStackingTestItem. ( Ignorable by Annotation )

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

200
                return $i->getItem()->/** @scrutinizer ignore-call */ canBePacked($box, $prevPackedItemList, $x, $y, $z, $i->getWidth(), $i->getLength(), $i->getDepth());
Loading history...
201 2
            });
202
        }
203
204 57
        return $orientations;
205
    }
206
207
    /**
208
     * @return OrientatedItem[]
209
     */
210 32
    public function getPossibleOrientationsInEmptyBox(Item $item): array
211
    {
212 32
        $cacheKey = $item->getWidth() .
213 32
            '|' .
214 32
            $item->getLength() .
215 32
            '|' .
216 32
            $item->getDepth() .
217 32
            '|' .
218 32
            ($item->getKeepFlat() ? '2D' : '3D') .
219 32
            '|' .
220 32
            $this->box->getInnerWidth() .
221 32
            '|' .
222 32
            $this->box->getInnerLength() .
223 32
            '|' .
224 32
            $this->box->getInnerDepth();
225
226 32
        if (isset(static::$emptyBoxCache[$cacheKey])) {
227 26
            $orientations = static::$emptyBoxCache[$cacheKey];
228
        } else {
229 28
            $orientations = $this->getPossibleOrientations(
230 28
                $item,
231 28
                null,
232 28
                $this->box->getInnerWidth(),
233 28
                $this->box->getInnerLength(),
234 28
                $this->box->getInnerDepth(),
235 28
                0,
236 28
                0,
237 28
                0,
238 28
                new PackedItemList()
239
            );
240 28
            static::$emptyBoxCache[$cacheKey] = $orientations;
241
        }
242
243 32
        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...
244
    }
245
246
    /**
247
     * @param OrientatedItem[] $possibleOrientations
248
     *
249
     * @return OrientatedItem[]
250
     */
251 56
    protected function getUsableOrientations(
252
        Item $item,
253
        array $possibleOrientations
254
    ): array {
255 56
        $orientationsToUse = $stableOrientations = $unstableOrientations = [];
256
257
        // Divide possible orientations into stable (low centre of gravity) and unstable (high centre of gravity)
258 56
        foreach ($possibleOrientations as $orientation) {
259 56
            if ($orientation->isStable() || $this->box->getInnerDepth() === $orientation->getDepth()) {
260 54
                $stableOrientations[] = $orientation;
261
            } else {
262 9
                $unstableOrientations[] = $orientation;
263
            }
264
        }
265
266
        /*
267
         * We prefer to use stable orientations only, but allow unstable ones if
268
         * the item doesn't fit in the box any other way
269
         */
270 56
        if (count($stableOrientations) > 0) {
271 54
            $orientationsToUse = $stableOrientations;
272 50
        } elseif (count($unstableOrientations) > 0) {
273 9
            $stableOrientationsInEmptyBox = $this->getStableOrientationsInEmptyBox($item);
274
275 9
            if (count($stableOrientationsInEmptyBox) === 0) {
276 6
                $orientationsToUse = $unstableOrientations;
277
            }
278
        }
279
280 56
        return $orientationsToUse;
281
    }
282
283
    /**
284
     * Return the orientations for this item if it were to be placed into the box with nothing else.
285
     */
286 9
    protected function getStableOrientationsInEmptyBox(Item $item): array
287
    {
288 9
        $orientationsInEmptyBox = $this->getPossibleOrientationsInEmptyBox($item);
289
290 9
        return array_filter(
291 9
            $orientationsInEmptyBox,
292
            function (OrientatedItem $orientation) {
293 9
                return $orientation->isStable();
294 9
            }
295
        );
296
    }
297
298
    /**
299
     * Approximation of a forward-looking packing.
300
     *
301
     * Not an actual packing, that has additional logic regarding constraints and stackability, this focuses
302
     * purely on fit.
303
     */
304 35
    protected function calculateAdditionalItemsPackedWithThisOrientation(
305
        OrientatedItem $prevItem,
306
        ItemList $nextItems,
307
        int $originalWidthLeft,
308
        int $originalLengthLeft,
309
        int $depthLeft,
310
        int $currentRowLengthBeforePacking
311
    ): int {
312 35
        $currentRowLength = max($prevItem->getLength(), $currentRowLengthBeforePacking);
313
314 35
        $itemsToPack = $nextItems->topN(8); // cap lookahead as this gets recursive and slow
315
316
        $cacheKey = $originalWidthLeft .
317 35
            '|' .
318 35
            $originalLengthLeft .
319 35
            '|' .
320 35
            $prevItem->getWidth() .
321 35
            '|' .
322 35
            $prevItem->getLength() .
323 35
            '|' .
324 35
            $currentRowLength .
325 35
            '|'
326 35
            . $depthLeft;
327
328
        /** @var Item $itemToPack */
329 35
        foreach ($itemsToPack as $itemToPack) {
330
            $cacheKey .= '|' .
331 35
                $itemToPack->getWidth() .
332 35
                '|' .
333 35
                $itemToPack->getLength() .
334 35
                '|' .
335 35
                $itemToPack->getDepth() .
336 35
                '|' .
337 35
                $itemToPack->getWeight() .
338 35
                '|' .
339 35
                ($itemToPack->getKeepFlat() ? '1' : '0');
340
        }
341
342 35
        if (!isset(static::$lookaheadCache[$cacheKey])) {
343 34
            $tempBox = new WorkingVolume($originalWidthLeft - $prevItem->getWidth(), $currentRowLength, $depthLeft, PHP_INT_MAX);
344 34
            $tempPacker = new VolumePacker($tempBox, $itemsToPack);
345 34
            $tempPacker->setSinglePassMode(true);
346 34
            $remainingRowPacked = $tempPacker->pack();
347
            /** @var PackedItem $packedItem */
348 34
            foreach ($remainingRowPacked->getItems() as $packedItem) {
349 24
                $itemsToPack->remove($packedItem->getItem());
350
            }
351
352 34
            $tempBox = new WorkingVolume($originalWidthLeft, $originalLengthLeft - $currentRowLength, $depthLeft, PHP_INT_MAX);
353 34
            $tempPacker = new VolumePacker($tempBox, $itemsToPack);
354 34
            $tempPacker->setSinglePassMode(true);
355 34
            $nextRowsPacked = $tempPacker->pack();
356
            /** @var PackedItem $packedItem */
357 34
            foreach ($nextRowsPacked->getItems() as $packedItem) {
358 21
                $itemsToPack->remove($packedItem->getItem());
359
            }
360
361 34
            $packedCount = $nextItems->count() - $itemsToPack->count();
362 34
            $this->logger->debug('Lookahead with orientation', ['packedCount' => $packedCount, 'orientatedItem' => $prevItem]);
363
364 34
            static::$lookaheadCache[$cacheKey] = $packedCount;
365
        }
366
367 35
        return static::$lookaheadCache[$cacheKey];
368
    }
369
}
370