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:
roman
2026-08-19 14:23:54 +02:00
co-authored by Claude Sonnet 5
parent 274d607d54
commit 654e1b79d0
10 changed files with 1011 additions and 440 deletions
+392 -203
View File
@@ -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>