Files
BoxPacker3D/LayerPacker.cs
T
2026-06-05 22:02:32 +02:00

315 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace BoxPacker3D
{
/// <summary>
/// Layer-based packing algorithm.
///
/// 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).
/// </summary>
public class LayerPacker
{
private const double EPS = 0.001;
public (List<PackedItem> packed, Box box, List<Item> unpacked) Pack(
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);
// ── 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);
double maxInnerVol = maxIW * maxIH * maxID;
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
.GroupBy(i => i.Name)
.Select(g => (Proto: g.First(), Items: g.ToList()))
.OrderByDescending(g => g.Proto.Volume)
.ToList();
// ── Steps 46: place first type ───────────────────────────────────────
var (proto0, items0) = types[0];
bool firstPlaced = false;
double extW = 0, extH = 0, extD = 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))
{
// 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;
// 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++)
{
packed.Add(new PackedItem(items0[idx++],
edge + ix * (iW + itemPad),
edge + iy * (iH + itemPad),
edge + iz * (iD + itemPad),
iW, iH, iD));
}
// Items that didn't fit (nH was capped) → unpacked
for (int i = idx; i < items0.Count; i++)
unpacked.Add(items0[i]);
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;
}
if (!firstPlaced)
{
unpacked.AddRange(allItems);
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);
// ── 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;
// 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);
}
// 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);
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>
/// 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.
/// </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)
{
if (queue.Count == 0 || rectW < EPS || rectD < EPS) return;
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
}
}
/// <summary>
/// Two allowed orientations: original and rotated 90° around vertical axis (W↔D).
/// H is always preserved — items cannot be tilted sideways.
/// </summary>
private static IEnumerable<(double iW, double iH, double iD)> Orientations(Item proto)
{
yield return (proto.W, proto.H, proto.D);
if (Math.Abs(proto.W - proto.D) > EPS)
yield return (proto.D, proto.H, proto.W);
}
/// <summary>
/// Shrinks the box to just enclose all packed items plus edge on every side.
/// </summary>
private static Box TightenBox(
List<PackedItem> packed, double edge,
Box minBox, Box maxBox, double wallThickness)
{
if (packed.Count == 0) return minBox;
double tw = Math.Clamp(Math.Ceiling(packed.Max(p => p.X + p.RW) + edge), minBox.W, maxBox.W);
double th = Math.Clamp(Math.Ceiling(packed.Max(p => p.Y + p.RH) + edge), minBox.H, maxBox.H);
double td = Math.Clamp(Math.Ceiling(packed.Max(p => p.Z + p.RD) + edge), minBox.D, maxBox.D);
return new Box(tw, th, td, wallThickness);
}
}
}