OrientatedItemSorter   A
last analyzed

Complexity

Total Complexity 23

Size/Duplication

Total Lines 159
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 4
Bugs 0 Features 0
Metric Value
eloc 79
c 4
b 0
f 0
dl 0
loc 159
ccs 84
cts 84
cp 1
rs 10
wmc 23

5 Methods

Rating   Name   Duplication   Size   Complexity  
A exactFitDecider() 0 11 5
B lookAheadDecider() 0 20 7
B __invoke() 0 35 6
A __construct() 0 14 1
A calculateAdditionalItemsPackedWithThisOrientation() 0 58 4
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\LoggerInterface;
12
13
use function max;
14
use function min;
15
16
use const PHP_INT_MAX;
17
18
/**
19
 * Figure out best choice of orientations for an item and a given context.
20
 * @internal
21
 */
22
class OrientatedItemSorter
23
{
24
    /**
25
     * @var array<string, int>
26
     */
27
    protected static array $lookaheadCache = [];
28
29 84
    public function __construct(
30
        private readonly OrientatedItemFactory $orientatedItemFactory,
31
        private readonly bool $singlePassMode,
32
        private readonly int $widthLeft,
33
        private readonly int $lengthLeft,
34
        private readonly int $depthLeft,
35
        private readonly ItemList $nextItems,
36
        private readonly int $rowLength,
37
        private readonly int $x,
38
        private readonly int $y,
39
        private readonly int $z,
40
        private readonly PackedItemList $prevPackedItemList,
41
        private readonly LoggerInterface $logger
42
    ) {
43 84
    }
44
45 34
    public function __invoke(OrientatedItem $a, OrientatedItem $b): int
46
    {
47
        // Prefer exact fits in width/length/depth order
48 34
        $orientationAWidthLeft = $this->widthLeft - $a->width;
49 34
        $orientationBWidthLeft = $this->widthLeft - $b->width;
50 34
        $widthDecider = $this->exactFitDecider($orientationAWidthLeft, $orientationBWidthLeft);
51 34
        if ($widthDecider !== 0) {
52 13
            return $widthDecider;
53
        }
54
55 31
        $orientationALengthLeft = $this->lengthLeft - $a->length;
56 31
        $orientationBLengthLeft = $this->lengthLeft - $b->length;
57 31
        $lengthDecider = $this->exactFitDecider($orientationALengthLeft, $orientationBLengthLeft);
58 31
        if ($lengthDecider !== 0) {
59 21
            return $lengthDecider;
60
        }
61
62 28
        $orientationADepthLeft = $this->depthLeft - $a->depth;
63 28
        $orientationBDepthLeft = $this->depthLeft - $b->depth;
64 28
        $depthDecider = $this->exactFitDecider($orientationADepthLeft, $orientationBDepthLeft);
65 28
        if ($depthDecider !== 0) {
66 4
            return $depthDecider;
67
        }
68
69
        // prefer leaving room for next item(s)
70 28
        $followingItemDecider = $this->lookAheadDecider($a, $b, $orientationAWidthLeft, $orientationBWidthLeft);
71 28
        if ($followingItemDecider !== 0) {
72 24
            return $followingItemDecider;
73
        }
74
75
        // otherwise prefer leaving minimum possible gap, or the greatest footprint
76 22
        $orientationAMinGap = min($orientationAWidthLeft, $orientationALengthLeft);
77 22
        $orientationBMinGap = min($orientationBWidthLeft, $orientationBLengthLeft);
78
79 22
        return $orientationAMinGap <=> $orientationBMinGap ?: $a->surfaceFootprint <=> $b->surfaceFootprint;
80
    }
81
82 28
    private function lookAheadDecider(OrientatedItem $a, OrientatedItem $b, int $orientationAWidthLeft, int $orientationBWidthLeft): int
83
    {
84 28
        if ($this->nextItems->count() === 0) {
85 16
            return 0;
86
        }
87
88 26
        $nextItemFitA = $this->orientatedItemFactory->getPossibleOrientations($this->nextItems->top(), $a, $orientationAWidthLeft, $this->lengthLeft, $this->depthLeft, $this->x, $this->y, $this->z, $this->prevPackedItemList);
89 26
        $nextItemFitB = $this->orientatedItemFactory->getPossibleOrientations($this->nextItems->top(), $b, $orientationBWidthLeft, $this->lengthLeft, $this->depthLeft, $this->x, $this->y, $this->z, $this->prevPackedItemList);
90 26
        if ($nextItemFitA && !$nextItemFitB) {
91 10
            return -1;
92
        }
93 25
        if ($nextItemFitB && !$nextItemFitA) {
94 19
            return 1;
95
        }
96
97
        // if not an easy either/or, do a partial lookahead
98 20
        $additionalPackedA = $this->calculateAdditionalItemsPackedWithThisOrientation($a);
99 20
        $additionalPackedB = $this->calculateAdditionalItemsPackedWithThisOrientation($b);
100
101 20
        return $additionalPackedB <=> $additionalPackedA ?: 0;
102
    }
103
104
    /**
105
     * Approximation of a forward-looking packing.
106
     *
107
     * Not an actual packing, that has additional logic regarding constraints and stackability, this focuses
108
     * purely on fit.
109
     */
110 20
    protected function calculateAdditionalItemsPackedWithThisOrientation(
111
        OrientatedItem $prevItem
112
    ): int {
113 20
        if ($this->singlePassMode) {
114 14
            return 0;
115
        }
116
117 20
        $currentRowLength = max($prevItem->length, $this->rowLength);
118
119 20
        $itemsToPack = $this->nextItems->topN(8); // cap lookahead as this gets recursive and slow
120
121 20
        $cacheKey = $this->widthLeft .
122 20
            '|' .
123 20
            $this->lengthLeft .
124 20
            '|' .
125 20
            $prevItem->width .
126 20
            '|' .
127 20
            $prevItem->length .
128 20
            '|' .
129 20
            $currentRowLength .
130 20
            '|'
131 20
            . $this->depthLeft;
132
133 20
        foreach ($itemsToPack as $itemToPack) {
134 20
            $cacheKey .= '|' .
135 20
                $itemToPack->getWidth() .
136 20
                '|' .
137 20
                $itemToPack->getLength() .
138 20
                '|' .
139 20
                $itemToPack->getDepth() .
140 20
                '|' .
141 20
                $itemToPack->getWeight() .
142 20
                '|' .
143 20
                $itemToPack->getAllowedRotation()->name;
144
        }
145
146 20
        if (!isset(static::$lookaheadCache[$cacheKey])) {
147 19
            $tempBox = new WorkingVolume($this->widthLeft - $prevItem->width, $currentRowLength, $this->depthLeft, PHP_INT_MAX);
148 19
            $tempPacker = new VolumePacker($tempBox, $itemsToPack);
149 19
            $tempPacker->setSinglePassMode(true);
150 19
            $remainingRowPacked = $tempPacker->pack();
151
152 19
            $itemsToPack->removePackedItems($remainingRowPacked->items);
153
154 19
            $tempBox = new WorkingVolume($this->widthLeft, $this->lengthLeft - $currentRowLength, $this->depthLeft, PHP_INT_MAX);
155 19
            $tempPacker = new VolumePacker($tempBox, $itemsToPack);
156 19
            $tempPacker->setSinglePassMode(true);
157 19
            $nextRowsPacked = $tempPacker->pack();
158
159 19
            $itemsToPack->removePackedItems($nextRowsPacked->items);
160
161 19
            $packedCount = $this->nextItems->count() - $itemsToPack->count();
162 19
            $this->logger->debug('Lookahead with orientation', ['packedCount' => $packedCount, 'orientatedItem' => $prevItem]);
163
164 19
            static::$lookaheadCache[$cacheKey] = $packedCount;
165
        }
166
167 20
        return static::$lookaheadCache[$cacheKey];
168
    }
169
170 34
    private function exactFitDecider(int $dimensionALeft, int $dimensionBLeft): int
171
    {
172 34
        if ($dimensionALeft === 0 && $dimensionBLeft > 0) {
173 18
            return -1;
174
        }
175
176 32
        if ($dimensionALeft > 0 && $dimensionBLeft === 0) {
177 18
            return 1;
178
        }
179
180 31
        return 0;
181
    }
182
}
183