Files
BoxPacker3D/LayerPacker.cs
T
romanandClaude Sonnet 5 654e1b79d0 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>
2026-08-19 14:23:54 +02:00

504 lines
25 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. 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 : IPacker
{
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, 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, 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<Item>();
int startIdx = 0;
List<PackedItem>? bestPacked = null;
List<Item>? 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<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
}
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);
}
/// <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);
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);
}
/// <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>
/// 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 (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)
{
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;
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>
/// 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);
}
}
}