using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace BoxPacker3D
{
///
/// 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. 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.
///
public class LayerPacker : IPacker
{
private const double EPS = 0.001;
public (List packed, Box box, List- unpacked) Pack(
List
- 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);
}
///
/// Same as , 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
/// ). Returns nW = nD = 0 when nothing could
/// be placed at all (no type fits inside maxBox).
///
public (List packed, Box box, List
- unpacked,
(double iW0, double iH0, double iD0) orient0, int nW, int nD) PackWithInfo(
List
- allItems,
Box minBox, Box maxBox,
double wallPad, double itemPad, double wallThickness)
{
var packed = new List();
var unpacked = new List
- ();
double edge = wallPad + wallThickness;
if (allItems.Count == 0)
return (packed, minBox, unpacked, default, 0, 0);
// ── Step 1: total padded volume ───────────────────────────────────────
double totalPaddedVol = allItems.Sum(i =>
(i.W + itemPad) * (i.H + itemPad) * (i.D + itemPad));
// ── Step 2: estimated box size via linear interpolation ───────────────
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));
double estW = minBox.W + t * (maxBox.W - minBox.W);
double estD = minBox.D + t * (maxBox.D - minBox.D);
double estIW = Math.Max(0, estW);
double estID = Math.Max(0, estD);
// ── 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()))
.ToList();
// 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
- ();
int startIdx = 0;
List? bestPacked = null;
List
- ? bestUnpacked = null;
(double iW0, double iH0, double iD0) bestOrient0 = default;
int bestNW = 0, bestND = 0;
while (startIdx < typeGroups.Count)
{
var proto0 = typeGroups[startIdx].Proto;
int count0 = typeGroups[startIdx].Items.Count;
var remainingTypeGroups = typeGroups.Skip(startIdx).ToList();
// 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))
{
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 triedPacked;
List
- 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
}
if (bestPacked != null) break; // found a workable footprint — done
// 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 (bestPacked == null)
{
// 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);
}
///
/// 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 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;
/// 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.
///
public (List packed, Box box, List
- unpacked) PackWithFixedFootprint(
List
- 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();
var unpacked = new List
- ();
double edge = wallPad + wallThickness;
if (allItems.Count == 0)
return (packed, minBox, unpacked);
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);
var typeGroups = allItems
.GroupBy(i => i.Name)
.Select(g => (Proto: g.First(), Items: g.ToList()))
.ToList();
var (p, unp) = TryPackAll(orient0, nW, nD, typeGroups, edge, itemPad, maxBox);
packed = p;
unpacked = unp;
if (packed.Count == 0)
return (packed, minBox, unpacked);
var box = TightenBox(packed, edge, minBox, maxBox, wallThickness);
return (packed, box, unpacked);
}
///
/// 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.
///
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; }
}
///
/// 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 —
/// 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.
///
private static (List packed, List
- unpacked) TryPackAll(
(double iW0, double iH0, double iD0) orient0, int nW, int nD,
List<(Item Proto, List
- Items)> typeGroups,
double edge, double itemPad, Box maxBox)
{
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
- (g.Items))).ToList();
var packed = new List();
var slots = new List { 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
- ();
foreach (var t in types)
unpacked.AddRange(t.Queue);
return (packed, unpacked);
}
///
/// Places ONE sub-layer of into ,
/// 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.
///
private static List PlaceIntoSlot(
Slot slot, Queue
- queue, (double iW, double iH, double iD) orient,
double itemPad, List 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();
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();
}
///
/// Picks the orientation of (from )
/// that fits the most items into rectW×rectD while respecting the layerH height cap.
/// If is supplied, that exact orientation is used instead
/// (used for type 0, whose footprint is tailored to its own dimensions).
///
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;
int cap = nW * nD;
if (cap > bestCap)
{
bestCap = cap;
best = (iW, iH, iD);
}
}
return best;
}
///
/// Searches a small neighbourhood around the estimated inner dimensions for the
/// (nW, nD) grid that needs the fewest stacked layers to hold
/// 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.
///
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;
}
///
/// Two allowed orientations: original and rotated 90° around vertical axis (W↔D).
/// H is always preserved — items cannot be tilted sideways.
///
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);
}
///
/// Shrinks the box to just enclose all packed items plus edge on every side.
///
private static Box TightenBox(
List 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);
}
}
}