Rewrite LayerPacker as skyline packer, add Extreme Points alternative, manual footprint tuning and reordering UI
- Replace the old layer-grid LayerPacker with a skyline (lowest-slot-first) algorithm: grows the footprint only when items truly can't fit within maxBox, lets shorter types stack multiple sub-layers into taller leftover space, cascades unfinished layers to later types, and allows a staircase profile to minimize wasted volume. - Fix ItemDatabaseEditor NumericUpDown crash (Value set before Minimum/Maximum) and ItemDatabase.Upsert doing INSERT instead of UPDATE (was keyed by Name, now by Id). - Add Packer3D as a working Extreme Points algorithm (was dead/commented-out code) behind a shared IPacker interface, selectable in the UI alongside Skyline. - Add manual footprint (Skyline) / box-size (Extreme Points) adjustment controls for the first box, with correct min/max clamping against the configured max box size. - Add a "Poradie" column with up/down buttons to reorder packing items; both packers now honor that manual order instead of re-sorting by volume. - Drop multi-box packing (single box only), add a pre-pack volume warning, and make unpacked items exportable to PDF / inspectable via a result-label tooltip, both grouped by type with dimensions, count and total volume. - Merge the min/max box dimension groups, hide the now-unused box navigation group, and misc UI cleanup (typo fix, group renames, wider item name column). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+392
-203
@@ -11,19 +11,29 @@ namespace BoxPacker3D
|
||||
/// 1. Compute total padded volume: each item slot = (W+p)×(H+p)×(D+p), sum all types.
|
||||
/// 2. t = paddedVol / maxInnerVol → estimate box via linear interpolation
|
||||
/// dim(t) = dim_min + t*(dim_max – dim_min) for W, H, D
|
||||
/// 3. Start with the type whose single item has the largest volume.
|
||||
/// Items may only rotate around the vertical axis (W↔D), H is always preserved.
|
||||
/// 4. Snap D: fit a whole number of items into estimated D.
|
||||
/// 5. Snap W: fit a whole number of items into estimated W.
|
||||
/// 6. Stack H: add as many layers upward as needed for all items of this type.
|
||||
/// Cap at maxBox.H; excess items → unpacked.
|
||||
/// 7. Subsequent types: add full horizontal layers on top (same W×D footprint).
|
||||
/// If the type does not fill a complete layer in original orientation, try W↔D rotation.
|
||||
/// 8. After every placement step, verify that no dimension exceeds maxBox.
|
||||
/// 9. Tighten box so wall insulation touches items on all six sides.
|
||||
/// 10. Items that do not fit → unpacked list (shown in PDF export).
|
||||
/// 3. Types keep whatever order they first appear in the input (manual priority —
|
||||
/// e.g. table row order in the UI — not sorted by volume). Items may only rotate
|
||||
/// around the vertical axis (W↔D); H is always preserved.
|
||||
/// 4. Type 0 (the first type) defines the box footprint: BestGrid picks (nW × nD)
|
||||
/// close to the Step-2 estimate. Type 0 is placed in a plain raster grid, layer by layer.
|
||||
/// 5. Every subsequent type reuses the SAME W×D footprint. A type is always fully
|
||||
/// consumed (or the box height runs out) before the next type is considered.
|
||||
/// If a type's own last layer is left with unused floor space (not enough items
|
||||
/// of that type to fill it), that space is offered to the NEXT type in line —
|
||||
/// and if that type still leaves space, to the type after that, and so on — until
|
||||
/// the area is filled or every type has been exhausted (only then is what remains
|
||||
/// insulation). This cascading fill is implemented once, uniformly, by FillArea,
|
||||
/// and applies both to a freshly opened layer and to left-over space inside one.
|
||||
/// 6. Nothing may end up unpacked unless it truly does not fit even at maxBox: if
|
||||
/// step 4/5 cannot place everything within the current footprint before the box
|
||||
/// height (maxBox.H) runs out, the footprint (nW or nD) is grown — within
|
||||
/// maxBox's W/D limits — and the WHOLE pack is retried from scratch. Only once
|
||||
/// the footprint is already at its maximum size and items still don't fit do
|
||||
/// they end up in the unpacked list (shown in the UI/PDF export).
|
||||
/// 7. TightenBox shrinks the final box so wall insulation touches items on all six
|
||||
/// sides.
|
||||
/// </summary>
|
||||
public class LayerPacker
|
||||
public class LayerPacker : IPacker
|
||||
{
|
||||
private const double EPS = 0.001;
|
||||
|
||||
@@ -31,22 +41,36 @@ namespace BoxPacker3D
|
||||
List<Item> allItems,
|
||||
Box minBox, Box maxBox,
|
||||
double wallPad, double itemPad, double wallThickness)
|
||||
{
|
||||
var (packed, box, unpacked, _, _, _) = PackWithInfo(allItems, minBox, maxBox, wallPad, itemPad, wallThickness);
|
||||
return (packed, box, unpacked);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="Pack"/>, but also reports the footprint that was actually used:
|
||||
/// type 0's chosen orientation and the (nW, nD) grid — needed by callers that want to
|
||||
/// let the user manually resize the footprint afterwards (see
|
||||
/// <see cref="PackWithFixedFootprint"/>). Returns <c>nW = nD = 0</c> when nothing could
|
||||
/// be placed at all (no type fits inside maxBox).
|
||||
/// </summary>
|
||||
public (List<PackedItem> packed, Box box, List<Item> unpacked,
|
||||
(double iW0, double iH0, double iD0) orient0, int nW, int nD) PackWithInfo(
|
||||
List<Item> allItems,
|
||||
Box minBox, Box maxBox,
|
||||
double wallPad, double itemPad, double wallThickness)
|
||||
{
|
||||
var packed = new List<PackedItem>();
|
||||
var unpacked = new List<Item>();
|
||||
double edge = wallPad + wallThickness;
|
||||
|
||||
if (allItems.Count == 0)
|
||||
return (packed, minBox, unpacked);
|
||||
return (packed, minBox, unpacked, default, 0, 0);
|
||||
|
||||
// ── Step 1: total padded volume ───────────────────────────────────────
|
||||
// Each item occupies (W+p)×(H+p)×(D+p) — padding is shared between
|
||||
// neighbours so only one layer per side is counted.
|
||||
double totalPaddedVol = allItems.Sum(i =>
|
||||
(i.W + itemPad) * (i.H + itemPad) * (i.D + itemPad));
|
||||
|
||||
// ── Step 2: estimated box size via linear interpolation ───────────────
|
||||
// t = ratio of needed inner volume to maximum available inner volume
|
||||
double maxIW = Math.Max(EPS, maxBox.W - 2 * edge);
|
||||
double maxIH = Math.Max(EPS, maxBox.H - 2 * edge);
|
||||
double maxID = Math.Max(EPS, maxBox.D - 2 * edge);
|
||||
@@ -54,236 +78,401 @@ namespace BoxPacker3D
|
||||
|
||||
double t = Math.Min(1.0, Math.Max(0.0, totalPaddedVol / maxInnerVol));
|
||||
|
||||
// a(t) = a_min + t*(a_max – a_min)
|
||||
double estW = minBox.W + t * (maxBox.W - minBox.W);
|
||||
double estH = minBox.H + t * (maxBox.H - minBox.H);
|
||||
double estD = minBox.D + t * (maxBox.D - minBox.D);
|
||||
|
||||
// Inner (usable) dimensions from the estimate
|
||||
double estIW = Math.Max(0, estW);
|
||||
double estIH = Math.Max(0, estH);
|
||||
double estID = Math.Max(0, estD);
|
||||
|
||||
// ── Step 3: sort types by single-item volume descending ───────────────
|
||||
var types = allItems
|
||||
// ── Step 3: type order — whatever order they first appear in allItems ──
|
||||
// (volume-based ordering was replaced by manual priority: the caller controls
|
||||
// this via the order items are listed in, e.g. the "Poradie" column in the UI —
|
||||
// GroupBy preserves first-occurrence order, so it just falls out of allItems).
|
||||
var typeGroups = allItems
|
||||
.GroupBy(i => i.Name)
|
||||
.Select(g => (Proto: g.First(), Items: g.ToList()))
|
||||
.OrderByDescending(g => g.Proto.Volume)
|
||||
.ToList();
|
||||
|
||||
// ── Steps 4–6: place first type ───────────────────────────────────────
|
||||
var (proto0, items0) = types[0];
|
||||
// A type can only define the footprint if it fits inside maxBox at all (even a
|
||||
// single unit). Types that don't (e.g. one item physically bigger than maxBox)
|
||||
// are permanently unpacked and skipped — the next-largest type takes over as
|
||||
// the footprint-defining "type 0" instead.
|
||||
var permanentlyUnpacked = new List<Item>();
|
||||
int startIdx = 0;
|
||||
|
||||
bool firstPlaced = false;
|
||||
double extW = 0, extH = 0, extD = 0;
|
||||
List<PackedItem>? bestPacked = null;
|
||||
List<Item>? bestUnpacked = null;
|
||||
(double iW0, double iH0, double iD0) bestOrient0 = default;
|
||||
int bestNW = 0, bestND = 0;
|
||||
|
||||
// Open (incomplete) last layer state
|
||||
double openLayerY = double.NaN;
|
||||
int openFilled = 0; // how many slots are already occupied
|
||||
int openCapacity = 0; // total slots in that layer (nW×nD)
|
||||
double openIW = 0, openIH = 0, openID = 0;
|
||||
int openNW = 0, openND = 0;
|
||||
|
||||
foreach (var (iW, iH, iD) in Orientations(proto0))
|
||||
while (startIdx < typeGroups.Count)
|
||||
{
|
||||
// Step 4: snap D to whole number of items (based on estimated D)
|
||||
int nD = Math.Max(1, (int)((estID + itemPad) / (iD + itemPad)));
|
||||
nD = Math.Min(nD, (int)((maxID + itemPad) / (iD + itemPad)));
|
||||
if (nD < 1) continue;
|
||||
var proto0 = typeGroups[startIdx].Proto;
|
||||
int count0 = typeGroups[startIdx].Items.Count;
|
||||
var remainingTypeGroups = typeGroups.Skip(startIdx).ToList();
|
||||
|
||||
// Step 5: snap W to whole number of items (based on estimated W)
|
||||
int nW = Math.Max(1, (int)((estIW + itemPad) / (iW + itemPad)));
|
||||
nW = Math.Min(nW, (int)((maxIW + itemPad) / (iW + itemPad)));
|
||||
if (nW < 1) continue;
|
||||
|
||||
// Step 6: H layers needed for all items; cap at maxBox.H
|
||||
int nH = (int)Math.Ceiling((double)items0.Count / (nD * nW));
|
||||
nH = Math.Min(nH, (int)((maxIH + itemPad) / (iH + itemPad)));
|
||||
if (nH < 1) continue;
|
||||
|
||||
// Place items layer by layer from bottom to top
|
||||
int idx = 0;
|
||||
for (int iy = 0; iy < nH && idx < items0.Count; iy++)
|
||||
for (int iz = 0; iz < nD && idx < items0.Count; iz++)
|
||||
for (int ix = 0; ix < nW && idx < items0.Count; ix++)
|
||||
// Try both orientations of this candidate type 0; keep whichever manages to
|
||||
// place everything, or — if neither can — whichever leaves fewer unpacked.
|
||||
foreach (var (iW0, iH0, iD0) in Orientations(proto0))
|
||||
{
|
||||
packed.Add(new PackedItem(items0[idx++],
|
||||
edge + ix * (iW + itemPad),
|
||||
edge + iy * (iH + itemPad),
|
||||
edge + iz * (iD + itemPad),
|
||||
iW, iH, iD));
|
||||
int maxNW = Math.Max(1, (int)((maxIW + itemPad) / (iW0 + itemPad)));
|
||||
int maxND = Math.Max(1, (int)((maxID + itemPad) / (iD0 + itemPad)));
|
||||
|
||||
var grid = BestGrid(iW0, iD0, estIW, estID, maxIW, maxID, count0, maxIH, iH0, itemPad);
|
||||
if (grid == null) continue; // this orientation can't fit even a single item0
|
||||
int nW = grid.Value.nW, nD = grid.Value.nD;
|
||||
|
||||
List<PackedItem> triedPacked;
|
||||
List<Item> triedUnpacked;
|
||||
int guard = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var (p, unp) = TryPackAll((iW0, iH0, iD0), nW, nD, remainingTypeGroups, edge, itemPad, maxBox);
|
||||
triedPacked = p;
|
||||
triedUnpacked = unp;
|
||||
|
||||
if (triedUnpacked.Count == 0) break;
|
||||
|
||||
bool canGrowW = nW < maxNW;
|
||||
bool canGrowD = nD < maxND;
|
||||
if (!canGrowW && !canGrowD) break;
|
||||
if (++guard > 2000) break; // safety cap
|
||||
|
||||
double wRoom = canGrowW ? (maxNW - nW) / (double)maxNW : -1;
|
||||
double dRoom = canGrowD ? (maxND - nD) / (double)maxND : -1;
|
||||
if (wRoom >= dRoom) nW++; else nD++;
|
||||
|
||||
Debug.WriteLine($"[LP] Grow footprint: nW={nW} nD={nD} (unpacked so far={triedUnpacked.Count})");
|
||||
}
|
||||
|
||||
if (bestUnpacked == null || triedUnpacked.Count < bestUnpacked.Count)
|
||||
{
|
||||
bestPacked = triedPacked;
|
||||
bestUnpacked = triedUnpacked;
|
||||
bestOrient0 = (iW0, iH0, iD0);
|
||||
bestNW = nW; bestND = nD;
|
||||
}
|
||||
if (triedUnpacked.Count == 0) break; // fully successful — no need to try the other orientation
|
||||
}
|
||||
|
||||
// Items that didn't fit (nH was capped) → unpacked
|
||||
for (int i = idx; i < items0.Count; i++)
|
||||
unpacked.Add(items0[i]);
|
||||
if (bestPacked != null) break; // found a workable footprint — done
|
||||
|
||||
extW = nW * (iW + itemPad) - itemPad;
|
||||
extH = nH * (iH + itemPad) - itemPad;
|
||||
extD = nD * (iD + itemPad) - itemPad;
|
||||
|
||||
// Track open last layer if it was not completely filled.
|
||||
// lastFilled == 0 when: (a) last layer is perfectly full, OR
|
||||
// (b) nH was capped so idx = nH*perLayer0 (exact multiple) — in
|
||||
// that case the last placed layer IS full, nothing to fill.
|
||||
// In both cases there is no open layer → openLayerY stays NaN.
|
||||
int perLayer0 = nW * nD;
|
||||
int lastFilled = idx % perLayer0;
|
||||
if (lastFilled > 0)
|
||||
{
|
||||
openLayerY = edge + (nH - 1) * (iH + itemPad);
|
||||
openFilled = lastFilled;
|
||||
openCapacity = perLayer0;
|
||||
openIW = iW; openIH = iH; openID = iD;
|
||||
openNW = nW; openND = nD;
|
||||
}
|
||||
|
||||
Debug.WriteLine(
|
||||
$"[LP] Type0 nW={nW} nD={nD} nH={nH} perLayer={perLayer0} " +
|
||||
$"idx={idx} lastFilled={lastFilled} " +
|
||||
$"openLayerY={openLayerY:F1} openIW={openIW} openIH={openIH} openID={openID}");
|
||||
|
||||
Debug.WriteLine($"[LayerPacker] Type0: nW={nW} nD={nD} nH={nH} perLayer={nW*nD} idx={idx} lastFilled={idx%(nW*nD)} openLayerY={openLayerY:F1} openIW={openIW} openIH={openIH} openID={openID}");
|
||||
|
||||
firstPlaced = true;
|
||||
break;
|
||||
// This type doesn't fit inside maxBox in either orientation at all —
|
||||
// it can never be placed, regardless of footprint. Skip it permanently
|
||||
// and try the next-largest type as the footprint definer instead.
|
||||
permanentlyUnpacked.AddRange(typeGroups[startIdx].Items);
|
||||
startIdx++;
|
||||
}
|
||||
|
||||
if (!firstPlaced)
|
||||
if (bestPacked == null)
|
||||
{
|
||||
unpacked.AddRange(allItems);
|
||||
// No type at all fits inside maxBox.
|
||||
unpacked.AddRange(permanentlyUnpacked);
|
||||
return (packed, minBox, unpacked, default, 0, 0);
|
||||
}
|
||||
|
||||
unpacked.AddRange(permanentlyUnpacked);
|
||||
unpacked.AddRange(bestUnpacked!);
|
||||
|
||||
var box = TightenBox(bestPacked, edge, minBox, maxBox, wallThickness);
|
||||
return (bestPacked, box, unpacked, bestOrient0, bestNW, bestND);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packs into a MANUALLY specified type-0 footprint (orientation + nW×nD grid),
|
||||
/// bypassing the automatic estimate/growth logic entirely — used when the user wants
|
||||
/// to hand-tune the footprint that <see cref="PackWithInfo"/> originally computed.
|
||||
/// nW/nD are clamped to what maxBox can physically hold. Types are the same
|
||||
/// "largest volume first" groups Pack/PackWithInfo would derive from allItems;
|
||||
/// <paramref name="orient0"/> must belong to whichever type ends up first after that
|
||||
/// sort (i.e. the same type 0 the original packing chose) or the footprint won't mean
|
||||
/// what the caller expects.
|
||||
/// </summary>
|
||||
public (List<PackedItem> packed, Box box, List<Item> unpacked) PackWithFixedFootprint(
|
||||
List<Item> allItems, Box minBox, Box maxBox,
|
||||
double wallPad, double itemPad, double wallThickness,
|
||||
(double iW0, double iH0, double iD0) orient0, int nW, int nD)
|
||||
{
|
||||
var packed = new List<PackedItem>();
|
||||
var unpacked = new List<Item>();
|
||||
double edge = wallPad + wallThickness;
|
||||
|
||||
if (allItems.Count == 0)
|
||||
return (packed, minBox, unpacked);
|
||||
}
|
||||
|
||||
// ── Step 7: subsequent types — fill open layer then full layers on top ─
|
||||
for (int ti = 1; ti < types.Count; ti++)
|
||||
{
|
||||
var (proto, typeItems) = types[ti];
|
||||
var queue = new Queue<Item>(typeItems);
|
||||
double maxIW = Math.Max(EPS, maxBox.W - 2 * edge);
|
||||
double maxID = Math.Max(EPS, maxBox.D - 2 * edge);
|
||||
int maxNW = Math.Max(1, (int)((maxIW + itemPad) / (orient0.iW0 + itemPad)));
|
||||
int maxND = Math.Max(1, (int)((maxID + itemPad) / (orient0.iD0 + itemPad)));
|
||||
nW = Math.Clamp(nW, 1, maxNW);
|
||||
nD = Math.Clamp(nD, 1, maxND);
|
||||
|
||||
// ── 7a: fill the open (incomplete) last layer of the previous type ─
|
||||
Debug.WriteLine(
|
||||
$"[LP] Type{ti} '{proto.Name}': openLayerY={openLayerY:F1} " +
|
||||
$"openFilled={openFilled} openCap={openCapacity} " +
|
||||
$"openIW={openIW} openIH={openIH} openID={openID}");
|
||||
if (!double.IsNaN(openLayerY) && queue.Count > 0)
|
||||
{
|
||||
int ixPartial = openFilled % openNW;
|
||||
int izPartial = openFilled / openNW;
|
||||
var typeGroups = allItems
|
||||
.GroupBy(i => i.Name)
|
||||
.Select(g => (Proto: g.First(), Items: g.ToList()))
|
||||
.ToList();
|
||||
|
||||
// Region A: X-remainder of the partially-filled Z-row.
|
||||
// Items must fit within openID depth to avoid overlapping Region B.
|
||||
if (ixPartial > 0)
|
||||
{
|
||||
double aXBase = edge + ixPartial * (openIW + itemPad);
|
||||
double aZBase = edge + izPartial * (openID + itemPad);
|
||||
FillRectangle(queue, packed,
|
||||
aXBase, aZBase,
|
||||
extW - ixPartial * (openIW + itemPad), openID,
|
||||
openLayerY, openIH, itemPad, proto);
|
||||
}
|
||||
var (p, unp) = TryPackAll(orient0, nW, nD, typeGroups, edge, itemPad, maxBox);
|
||||
packed = p;
|
||||
unpacked = unp;
|
||||
|
||||
// Region B: completely free Z-rows (uses both orientations via FillRectangle).
|
||||
int izFree = izPartial + (ixPartial > 0 ? 1 : 0);
|
||||
double bZOff = izFree * (openID + itemPad);
|
||||
double bRectD = extD - bZOff;
|
||||
if (bRectD > EPS)
|
||||
FillRectangle(queue, packed,
|
||||
edge, edge + bZOff, extW, bRectD,
|
||||
openLayerY, openIH, itemPad, proto);
|
||||
if (packed.Count == 0)
|
||||
return (packed, minBox, unpacked);
|
||||
|
||||
openLayerY = double.NaN;
|
||||
}
|
||||
|
||||
// ── 7b: add layers on top, filling each layer with both orientations ─
|
||||
foreach (var (iW, iH, iD) in Orientations(proto))
|
||||
{
|
||||
if (queue.Count == 0) break;
|
||||
|
||||
// Step 8: check remaining height
|
||||
double availH = maxBox.H - 2 * edge - extH;
|
||||
int nHavail = (int)((availH + itemPad) / (iH + itemPad));
|
||||
if (nHavail < 1) continue;
|
||||
|
||||
// Verify at least one item fits per layer in this orientation
|
||||
if ((int)((extW + itemPad) / (iW + itemPad)) < 1) continue;
|
||||
if ((int)((extD + itemPad) / (iD + itemPad)) < 1) continue;
|
||||
|
||||
while (queue.Count > 0 && nHavail > 0)
|
||||
{
|
||||
int before = queue.Count;
|
||||
double y0 = edge + extH + itemPad;
|
||||
// Fill the full extW×extD footprint using both orientations.
|
||||
// FillRectangle recurses into remaining strips with rotated items.
|
||||
FillRectangle(queue, packed, edge, edge, extW, extD,
|
||||
y0, iH, itemPad, proto);
|
||||
if (queue.Count == before) break; // items too large for remaining space
|
||||
extH += iH + itemPad;
|
||||
nHavail--;
|
||||
}
|
||||
|
||||
openLayerY = double.NaN;
|
||||
break;
|
||||
}
|
||||
|
||||
// Leftover items → unpacked
|
||||
unpacked.AddRange(queue);
|
||||
}
|
||||
|
||||
// ── Step 9: tighten box ───────────────────────────────────────────────
|
||||
var box = TightenBox(packed, edge, minBox, maxBox, wallThickness);
|
||||
return (packed, box, unpacked);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// A free rectangular floor area whose current "height" (the Y an item placed here
|
||||
/// would start at) may differ from every other slot's — this is what allows different
|
||||
/// parts of the footprint to reach different heights (a "staircase") instead of every
|
||||
/// part waiting for a single shared layer height.
|
||||
/// </summary>
|
||||
private readonly struct Slot
|
||||
{
|
||||
public readonly double X, Z, W, D, Y;
|
||||
public Slot(double x, double z, double w, double d, double y) { X = x; Z = z; W = w; D = d; Y = y; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills a rectangular horizontal region (xBase,zBase)+(rectW×rectD) at height y
|
||||
/// with items from queue, trying both W/D orientations. After placing the primary
|
||||
/// grid the remaining two strips (Z-remainder and X-remainder) are filled recursively,
|
||||
/// so rotated items can be used to fill gaps left by the primary orientation.
|
||||
/// layerH caps the maximum item height that may be placed.
|
||||
/// Packs every type (type 0 included) into a fixed nW×nD footprint (derived from
|
||||
/// type 0's chosen orientation). Starts from one slot covering the whole footprint
|
||||
/// and repeatedly places ONE layer of the current type into whichever free slot is
|
||||
/// currently lowest (ties broken by largest area) — see <see cref="PlaceIntoSlot"/> —
|
||||
/// re-evaluating after every single layer so a type spreads breadth-first across
|
||||
/// same-height areas instead of being stacked entirely into one narrow column. Once
|
||||
/// no equally-low area is left for it, later layers of the same type naturally climb
|
||||
/// higher (a "staircase") rather than everything waiting for the tallest neighbour.
|
||||
/// A type is only skipped once it no longer fits into ANY current slot. Whatever
|
||||
/// cannot be placed because the box height (maxBox.H) is exhausted is returned as
|
||||
/// unpacked.
|
||||
/// </summary>
|
||||
private static void FillRectangle(
|
||||
Queue<Item> queue, List<PackedItem> packed,
|
||||
double xBase, double zBase, double rectW, double rectD,
|
||||
double y, double layerH, double itemPad, Item proto)
|
||||
private static (List<PackedItem> packed, List<Item> unpacked) TryPackAll(
|
||||
(double iW0, double iH0, double iD0) orient0, int nW, int nD,
|
||||
List<(Item Proto, List<Item> Items)> typeGroups,
|
||||
double edge, double itemPad, Box maxBox)
|
||||
{
|
||||
if (queue.Count == 0 || rectW < EPS || rectD < EPS) return;
|
||||
double extW = nW * (orient0.iW0 + itemPad) - itemPad;
|
||||
double extD = nD * (orient0.iD0 + itemPad) - itemPad;
|
||||
double maxTopY = edge + Math.Max(EPS, maxBox.H - 2 * edge);
|
||||
|
||||
var types = typeGroups.Select(g => (Proto: g.Proto, Queue: new Queue<Item>(g.Items))).ToList();
|
||||
var packed = new List<PackedItem>();
|
||||
var slots = new List<Slot> { new Slot(edge, edge, extW, extD, edge) };
|
||||
|
||||
int typeIdx = 0;
|
||||
while (typeIdx < types.Count)
|
||||
{
|
||||
if (types[typeIdx].Queue.Count == 0) { typeIdx++; continue; }
|
||||
|
||||
var proto = types[typeIdx].Proto;
|
||||
var queue = types[typeIdx].Queue;
|
||||
(double, double, double)? forced = typeIdx == 0 ? orient0 : ((double, double, double)?)null;
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
int bestIdx = -1;
|
||||
double bestY = double.MaxValue, bestArea = -1;
|
||||
(double iW, double iH, double iD) bestOrient = default;
|
||||
|
||||
for (int i = 0; i < slots.Count; i++)
|
||||
{
|
||||
var s = slots[i];
|
||||
double availH = maxTopY - s.Y;
|
||||
if (availH <= EPS) continue;
|
||||
var ch = PickOrientation(proto, s.W, s.D, availH, itemPad, forced);
|
||||
if (ch == null) continue;
|
||||
double area = s.W * s.D;
|
||||
if (s.Y < bestY - EPS || (Math.Abs(s.Y - bestY) <= EPS && area > bestArea))
|
||||
{
|
||||
bestY = s.Y; bestArea = area; bestIdx = i; bestOrient = ch.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx < 0) break; // this type doesn't fit into any current slot right now
|
||||
|
||||
var slot = slots[bestIdx];
|
||||
slots.RemoveAt(bestIdx);
|
||||
slots.AddRange(PlaceIntoSlot(slot, queue, bestOrient, itemPad, packed));
|
||||
}
|
||||
|
||||
typeIdx++; // exhausted, or doesn't fit anywhere currently — move on to the next type
|
||||
}
|
||||
|
||||
var unpacked = new List<Item>();
|
||||
foreach (var t in types)
|
||||
unpacked.AddRange(t.Queue);
|
||||
|
||||
return (packed, unpacked);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places ONE sub-layer of <paramref name="orient"/> into <paramref name="slot"/>,
|
||||
/// then splits whatever's left of the slot into new, geometrically disjoint slots —
|
||||
/// the used footprint at its new (taller) height, and whatever wasn't touched (a
|
||||
/// partially filled row, or the boundary strip left when the footprint isn't an exact
|
||||
/// multiple of the item) still at the slot's original height. Only one layer is placed
|
||||
/// per call — the caller re-evaluates which slot is lowest before every placement —
|
||||
/// so a type spreads across same-height areas breadth-first instead of being stacked
|
||||
/// entirely into one narrow column before its neighbours are considered. Different
|
||||
/// areas of the footprint can still end up at different heights (a staircase) once
|
||||
/// their available slots genuinely differ.
|
||||
/// </summary>
|
||||
private static List<Slot> PlaceIntoSlot(
|
||||
Slot slot, Queue<Item> queue, (double iW, double iH, double iD) orient,
|
||||
double itemPad, List<PackedItem> packed)
|
||||
{
|
||||
var (iW, iH, iD) = orient;
|
||||
int nW = (int)((slot.W + itemPad) / (iW + itemPad));
|
||||
int nD = (int)((slot.D + itemPad) / (iD + itemPad));
|
||||
int cap = nW * nD;
|
||||
|
||||
double gridW = nW * (iW + itemPad) - itemPad;
|
||||
double gridD = nD * (iD + itemPad) - itemPad;
|
||||
double zRemain = slot.D - nD * (iD + itemPad);
|
||||
double xRemain = slot.W - nW * (iW + itemPad);
|
||||
|
||||
int filled = 0;
|
||||
for (int iz = 0; iz < nD && queue.Count > 0; iz++)
|
||||
for (int ix = 0; ix < nW && queue.Count > 0; ix++)
|
||||
{
|
||||
packed.Add(new PackedItem(queue.Dequeue(),
|
||||
slot.X + ix * (iW + itemPad), slot.Y, slot.Z + iz * (iD + itemPad), iW, iH, iD));
|
||||
filled++;
|
||||
}
|
||||
|
||||
double newY = slot.Y + (iH + itemPad);
|
||||
var result = new List<Slot>();
|
||||
|
||||
if (filled < cap)
|
||||
{
|
||||
// Ran out mid-row: the cells that DID get this layer end up taller (newY)
|
||||
// than the ones that didn't (still slot.Y) — split accordingly.
|
||||
int filledRows = filled / nW;
|
||||
int filledInRow = filled % nW;
|
||||
|
||||
if (filledInRow > 0)
|
||||
{
|
||||
result.Add(new Slot(slot.X, slot.Z + filledRows * (iD + itemPad),
|
||||
filledInRow * (iW + itemPad) - itemPad, iD, newY));
|
||||
result.Add(new Slot(slot.X + filledInRow * (iW + itemPad), slot.Z + filledRows * (iD + itemPad),
|
||||
gridW - filledInRow * (iW + itemPad), iD, slot.Y));
|
||||
}
|
||||
if (filledRows > 0)
|
||||
result.Add(new Slot(slot.X, slot.Z, gridW, filledRows * (iD + itemPad) - itemPad, newY));
|
||||
|
||||
// Rows of the grid beyond what we attempted (still within the grid's own
|
||||
// width — the boundary strip beyond gridW is a SEPARATE slot, added below;
|
||||
// using the full slot width here would double-claim that strip).
|
||||
int fullRowsUsed = filledRows + (filledInRow > 0 ? 1 : 0);
|
||||
double zOff = fullRowsUsed * (iD + itemPad);
|
||||
double remGridD = gridD - zOff;
|
||||
if (remGridD > EPS)
|
||||
result.Add(new Slot(slot.X, slot.Z + zOff, gridW, remGridD, slot.Y));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Layer fully placed — one slot for the used footprint at its new (taller) height.
|
||||
result.Add(new Slot(slot.X, slot.Z, gridW, gridD, newY));
|
||||
}
|
||||
|
||||
// Boundary strips (footprint not an exact multiple of this orientation) never got
|
||||
// touched — they stay at the slot's ORIGINAL height, as fresh slots of their own,
|
||||
// so the main loop can offer them (with full remaining height, not capped) to this
|
||||
// same type again or to the next type in line.
|
||||
if (zRemain > EPS)
|
||||
result.Add(new Slot(slot.X, slot.Z + nD * (iD + itemPad), slot.W, zRemain, slot.Y));
|
||||
if (xRemain > EPS)
|
||||
result.Add(new Slot(slot.X + nW * (iW + itemPad), slot.Z, xRemain, gridD, slot.Y));
|
||||
|
||||
return result.Where(s => s.W > EPS && s.D > EPS).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the orientation of <paramref name="proto"/> (from <see cref="Orientations"/>)
|
||||
/// that fits the most items into rectW×rectD while respecting the layerH height cap.
|
||||
/// If <paramref name="forced"/> is supplied, that exact orientation is used instead
|
||||
/// (used for type 0, whose footprint is tailored to its own dimensions).
|
||||
/// </summary>
|
||||
private static (double iW, double iH, double iD)? PickOrientation(
|
||||
Item proto, double rectW, double rectD, double layerH, double itemPad,
|
||||
(double, double, double)? forced)
|
||||
{
|
||||
if (forced.HasValue)
|
||||
{
|
||||
var (fw, fh, fd) = forced.Value;
|
||||
return fh <= layerH + EPS ? forced : null;
|
||||
}
|
||||
|
||||
(double iW, double iH, double iD)? best = null;
|
||||
int bestCap = 0;
|
||||
foreach (var (iW, iH, iD) in Orientations(proto))
|
||||
{
|
||||
if (iH > layerH + EPS) continue;
|
||||
|
||||
int nW = (int)((rectW + itemPad) / (iW + itemPad));
|
||||
int nD = (int)((rectD + itemPad) / (iD + itemPad));
|
||||
if (nW < 1 || nD < 1) continue;
|
||||
|
||||
for (int iz = 0; iz < nD && queue.Count > 0; iz++)
|
||||
for (int ix = 0; ix < nW && queue.Count > 0; ix++)
|
||||
packed.Add(new PackedItem(queue.Dequeue(),
|
||||
xBase + ix * (iW + itemPad),
|
||||
y,
|
||||
zBase + iz * (iD + itemPad),
|
||||
iW, iH, iD));
|
||||
|
||||
// Z-strip: depth remaining after primary grid rows
|
||||
double zRemain = rectD - nD * (iD + itemPad);
|
||||
if (zRemain > EPS && queue.Count > 0)
|
||||
FillRectangle(queue, packed,
|
||||
xBase, zBase + nD * (iD + itemPad), rectW, zRemain,
|
||||
y, layerH, itemPad, proto);
|
||||
|
||||
// X-strip: width remaining alongside primary grid's Z range
|
||||
double xRemain = rectW - nW * (iW + itemPad);
|
||||
if (xRemain > EPS && queue.Count > 0)
|
||||
FillRectangle(queue, packed,
|
||||
xBase + nW * (iW + itemPad), zBase,
|
||||
xRemain, nD * (iD + itemPad),
|
||||
y, layerH, itemPad, proto);
|
||||
|
||||
break; // orientation chosen — done
|
||||
int cap = nW * nD;
|
||||
if (cap > bestCap)
|
||||
{
|
||||
bestCap = cap;
|
||||
best = (iW, iH, iD);
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches a small neighbourhood around the estimated inner dimensions for the
|
||||
/// (nW, nD) grid that needs the fewest stacked layers to hold <paramref name="count"/>
|
||||
/// items, instead of naively flooring the estimate (which systematically
|
||||
/// under-sizes the footprint). Ties are broken by whichever footprint area is
|
||||
/// closest to the estimated footprint area.
|
||||
/// </summary>
|
||||
private static (int nW, int nD)? BestGrid(
|
||||
double iW, double iD, double estIW, double estID,
|
||||
double maxIW, double maxID, int count, double maxIH, double iH, double itemPad)
|
||||
{
|
||||
int maxNW = Math.Max(1, (int)((maxIW + itemPad) / (iW + itemPad)));
|
||||
int maxND = Math.Max(1, (int)((maxID + itemPad) / (iD + itemPad)));
|
||||
|
||||
int roundW = (int)Math.Round((estIW + itemPad) / (iW + itemPad), MidpointRounding.AwayFromZero);
|
||||
int roundD = (int)Math.Round((estID + itemPad) / (iD + itemPad), MidpointRounding.AwayFromZero);
|
||||
|
||||
(int nW, int nD)? bestGrid = null;
|
||||
int bestNH = int.MaxValue;
|
||||
double bestAreaDiff = double.MaxValue;
|
||||
double estArea = estIW * estID;
|
||||
|
||||
for (int dOff = -1; dOff <= 1; dOff++)
|
||||
{
|
||||
int nD = Math.Clamp(roundD + dOff, 1, maxND);
|
||||
for (int wOff = -1; wOff <= 1; wOff++)
|
||||
{
|
||||
int nW = Math.Clamp(roundW + wOff, 1, maxNW);
|
||||
|
||||
int nH = (int)Math.Ceiling((double)count / (nW * nD));
|
||||
int capNH = (int)((maxIH + itemPad) / (iH + itemPad));
|
||||
if (capNH < 1) continue;
|
||||
nH = Math.Min(nH, capNH);
|
||||
if (nH < 1) continue;
|
||||
|
||||
double area = (nW * (iW + itemPad)) * (nD * (iD + itemPad));
|
||||
double areaDiff = Math.Abs(area - estArea);
|
||||
|
||||
bool better = nH < bestNH || (nH == bestNH && areaDiff < bestAreaDiff);
|
||||
if (better)
|
||||
{
|
||||
bestNH = nH;
|
||||
bestAreaDiff = areaDiff;
|
||||
bestGrid = (nW, nD);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestGrid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user