- 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>
504 lines
25 KiB
C#
504 lines
25 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|