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
+1
View File
@@ -361,3 +361,4 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
/binCheck 2
+19 -3
View File
@@ -44,6 +44,14 @@
"PrerequisitesLocation" = "2:1"
"Url" = "8:"
"ComponentsUrl" = "8:"
"Items"
{
"{EDC2488A-8267-493A-A98E-7D9C3B36CDF3}:.NETFramework,Version=v4.7.2"
{
"Name" = "8:Microsoft .NET Framework 4.7.2 (x86 and x64)"
"ProductCode" = "8:.NETFramework,Version=v4.7.2"
}
}
}
}
"Release"
@@ -68,6 +76,14 @@
"PrerequisitesLocation" = "2:1"
"Url" = "8:"
"ComponentsUrl" = "8:"
"Items"
{
"{EDC2488A-8267-493A-A98E-7D9C3B36CDF3}:.NETFramework,Version=v4.7.2"
{
"Name" = "8:Microsoft .NET Framework 4.7.2 (x86 and x64)"
"ProductCode" = "8:.NETFramework,Version=v4.7.2"
}
}
}
}
}
@@ -165,15 +181,15 @@
{
"Name" = "8:Microsoft Visual Studio"
"ProductName" = "8:3D Box Packer"
"ProductCode" = "8:{AD850FE7-45D6-4A75-BA48-14975E8E19D4}"
"PackageCode" = "8:{0DB6CFE3-A763-4935-B9A2-8FF88E8EAE76}"
"ProductCode" = "8:{FE12B7F9-9B56-4E84-87AB-DC02C8A15113}"
"PackageCode" = "8:{784F648E-B453-46B7-A4EB-028B03475A69}"
"UpgradeCode" = "8:{73799267-8F2E-4994-AE60-A0CFA8390BEE}"
"AspNetVersion" = "8:"
"RestartWWWService" = "11:FALSE"
"RemovePreviousVersions" = "11:TRUE"
"DetectNewerInstalledVersion" = "11:TRUE"
"InstallAllUsers" = "11:FALSE"
"ProductVersion" = "8:1.0.1"
"ProductVersion" = "8:1.0.3"
"Manufacturer" = "8:Your Company"
"ARPHELPTELEPHONE" = "8:"
"ARPHELPLINK" = "8:"
+1
View File
@@ -11,6 +11,7 @@
<ApplicationIcon></ApplicationIcon>
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyVersion>1.0.3</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
+432 -197
View File
@@ -13,6 +13,19 @@ namespace BoxPacker3D
{
public Box Box;
public List<PackedItem> PackedItems;
// Inputs kept so the first box can later be re-packed with a manually adjusted
// footprint (Skyline) or box size (Extreme Points) without re-running BtnPack_Click.
public List<Item> SourceItems = new List<Item>();
public Box MinBox = new Box(0, 0, 0);
public Box MaxBox = new Box(0, 0, 0);
public Box HardMaxBox = new Box(0, 0, 0); // configured max at pack time — never mutated, used as the ceiling for manual adjustment
public double WallPad, ItemPad, WallThickness;
public bool IsSkyline;
public (double iW0, double iH0, double iD0) Orient0;
public int NW, ND;
public List<Item> Unpacked = new List<Item>();
public BoxSolution(Box box, List<PackedItem> packed)
{ Box = box; PackedItems = packed; }
}
@@ -26,6 +39,7 @@ namespace BoxPacker3D
private List<BoxSolution> _solutions = new List<BoxSolution>();
private List<Item> _unpackedItems = new List<Item>(); // Track items that didn't fit
private int _currentBoxIndex = 0;
private ToolTip _unpackedTooltip = new ToolTip { AutoPopDelay = 30000, InitialDelay = 300, ReshowDelay = 100 };
private List<ItemType> itemTypes = new List<ItemType>();
private Viewer3D viewer;
@@ -40,6 +54,10 @@ namespace BoxPacker3D
private ComboBox cmbItemName;
private NumericUpDown nudNewW, nudNewH, nudNewD, nudNewCnt;
private Button btnAddItem, btnRemoveItem, btnPack, btnSuggest, btnClear, btnSaveTemplate, btnEditDb, btnRefreshDb;
private CheckBox chkSkyline, chkExtremePoints;
private Label lblFootprintW, lblFootprintD;
private NumericUpDown nudFootprintW, nudFootprintD;
private bool _suppressFootprintEvents = false;
private Button btnExportJpg, btnExportPng, btnExportPdf;
private Button btnPrevBox, btnNextBox;
private Label lblEfficiency, lblPacked, lblTotal, lblBoxNav;
@@ -149,30 +167,6 @@ namespace BoxPacker3D
tbl.Controls.Add(ctrl, 0, row);
}
private TableLayoutPanel Make3ColNudRow(
string lbl1, NumericUpDown nud1,
string lbl2, NumericUpDown nud2,
string lbl3, NumericUpDown nud3)
{
var tbl = new TableLayoutPanel
{
Dock = DockStyle.Fill, AutoSize = true,
ColumnCount = 3, RowCount = 2, Margin = new Padding(2)
};
tbl.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33f));
tbl.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33f));
tbl.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 34f));
tbl.RowStyles.Add(new RowStyle(SizeType.AutoSize));
tbl.RowStyles.Add(new RowStyle(SizeType.AutoSize));
tbl.Controls.Add(MakeLabel(lbl1), 0, 0);
tbl.Controls.Add(MakeLabel(lbl2), 1, 0);
tbl.Controls.Add(MakeLabel(lbl3), 2, 0);
tbl.Controls.Add(nud1, 0, 1);
tbl.Controls.Add(nud2, 1, 1);
tbl.Controls.Add(nud3, 2, 1);
return tbl;
}
private void BuildUI()
{
this.Text = "3D Box Packer";
@@ -235,35 +229,12 @@ namespace BoxPacker3D
panelLeft.Controls.Add(stack);
int r = 0;
// ── Min box dimensions (mm) ───────────────────────────────────────
grpBox = MakeGroupBox("📦 Min rozmery škatule (mm)");
// ── Min & Max box dimensions (mm) ─────────────────────────────────
grpBox = MakeGroupBox("📦 Min a Max rozmery škatule (mm)");
nudBoxW = MakeNUD((decimal)_settings.DefaultBox.Width, 10);
nudBoxH = MakeNUD((decimal)_settings.DefaultBox.Height, 10);
nudBoxD = MakeNUD((decimal)_settings.DefaultBox.Depth, 10);
nudBoxWallThickness = MakeNUD((decimal)_settings.DefaultBox.WallThickness, 0, 500, 1, 0.5m);
// 3-column label+NUD row (wall thickness is in Izolácia group)
var boxTbl = new TableLayoutPanel
{
Dock = DockStyle.Fill, AutoSize = true,
ColumnCount = 3, RowCount = 2, Margin = new Padding(2)
};
boxTbl.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33f));
boxTbl.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33f));
boxTbl.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 34f));
boxTbl.RowStyles.Add(new RowStyle(SizeType.AutoSize));
boxTbl.RowStyles.Add(new RowStyle(SizeType.AutoSize));
boxTbl.Controls.Add(MakeLabel("Min šírka (mm):"), 0, 0);
boxTbl.Controls.Add(MakeLabel("Min dĺžka (mm):"), 1, 0);
boxTbl.Controls.Add(MakeLabel("Min výška (mm):"), 2, 0);
boxTbl.Controls.Add(nudBoxW, 0, 1);
boxTbl.Controls.Add(nudBoxD, 1, 1);
boxTbl.Controls.Add(nudBoxH, 2, 1);
grpBox.Controls.Add(boxTbl);
PushRow(stack, grpBox, r++);
// ── Max dimensions ────────────────────────────────────────────────
var grpMax = MakeGroupBox("📏 Max rozmery škatule (mm)");
nudMaxW = MakeNUD((decimal)_settings.MaxBox.Width, 10);
nudMaxH = MakeNUD((decimal)_settings.MaxBox.Height, 10);
nudMaxD = MakeNUD((decimal)_settings.MaxBox.Depth, 10);
@@ -282,8 +253,33 @@ namespace BoxPacker3D
if (nudBoxD.Value > nudMaxD.Value) nudBoxD.Value = nudMaxD.Value;
};
grpMax.Controls.Add(Make3ColNudRow("Max šírka:", nudMaxW, "Max dĺžka:", nudMaxD, "Max výška:", nudMaxH));
PushRow(stack, grpMax, r++);
// 4-column table: label column + Šírka/Dĺžka/Výška, one row for Min, one for Max
var boxTbl = new TableLayoutPanel
{
Dock = DockStyle.Fill, AutoSize = true,
ColumnCount = 4, RowCount = 3, Margin = new Padding(2)
};
boxTbl.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 40f));
boxTbl.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33f));
boxTbl.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33f));
boxTbl.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 34f));
boxTbl.RowStyles.Add(new RowStyle(SizeType.AutoSize));
boxTbl.RowStyles.Add(new RowStyle(SizeType.AutoSize));
boxTbl.RowStyles.Add(new RowStyle(SizeType.AutoSize));
boxTbl.Controls.Add(MakeLabel(""), 0, 0);
boxTbl.Controls.Add(MakeLabel("Šírka (mm):"), 1, 0);
boxTbl.Controls.Add(MakeLabel("Dĺžka (mm):"), 2, 0);
boxTbl.Controls.Add(MakeLabel("Výška (mm):"), 3, 0);
boxTbl.Controls.Add(MakeLabel("Min:"), 0, 1);
boxTbl.Controls.Add(nudBoxW, 1, 1);
boxTbl.Controls.Add(nudBoxD, 2, 1);
boxTbl.Controls.Add(nudBoxH, 3, 1);
boxTbl.Controls.Add(MakeLabel("Max:"), 0, 2);
boxTbl.Controls.Add(nudMaxW, 1, 2);
boxTbl.Controls.Add(nudMaxD, 2, 2);
boxTbl.Controls.Add(nudMaxH, 3, 2);
grpBox.Controls.Add(boxTbl);
PushRow(stack, grpBox, r++);
// ── Padding + wall thickness (mm) ────────────────────────────────
var grpPad = MakeGroupBox("🧱 Izolácia (mm)");
@@ -300,7 +296,7 @@ namespace BoxPacker3D
padTbl.RowStyles.Add(new RowStyle(SizeType.AutoSize));
padTbl.RowStyles.Add(new RowStyle(SizeType.AutoSize));
padTbl.Controls.Add(MakeLabel("Stenová izolácia:"), 0, 0);
padTbl.Controls.Add(MakeLabel("Medikusová:"), 1, 0);
padTbl.Controls.Add(MakeLabel("Medzikusová:"), 1, 0);
padTbl.Controls.Add(MakeLabel("Hrúbka steny:"), 2, 0);
padTbl.Controls.Add(nudWallPad, 0, 1);
padTbl.Controls.Add(nudItemPad, 1, 1);
@@ -319,7 +315,8 @@ namespace BoxPacker3D
dgvItems = new DataGridView
{
Dock = DockStyle.Fill, Height = 160,
// Height grown by ~the space the (now hidden) box-navigation group used to take.
Dock = DockStyle.Fill, Height = 230,
BackgroundColor = C(40, 40, 55), GridColor = C(60, 60, 80),
BorderStyle = BorderStyle.None, RowHeadersVisible = false,
AllowUserToAddRows = false,
@@ -339,8 +336,32 @@ namespace BoxPacker3D
dgvItems.Columns.Add("H", "V(mm)");
dgvItems.Columns.Add("D", "D(mm)");
dgvItems.Columns.Add("Count", "Počet");
dgvItems.Columns["Name"].FillWeight = 150f; // 1.5× the default 100 fill weight
dgvItems.Columns["W"].FillWeight = 85f;
dgvItems.Columns["H"].FillWeight = 85f;
dgvItems.Columns["D"].FillWeight = 85f;
dgvItems.Columns["Count"].FillWeight = 85f;
// "Poradie" — a fixed-width column of up/down move buttons, hand-painted (no
// bound value); manual re-ordering of packing priority. Volume-based ordering
// for the packer itself is intentionally left untouched for now.
var orderCol = new DataGridViewTextBoxColumn
{
Name = "Order",
HeaderText = "Poradie",
ReadOnly = true,
SortMode = DataGridViewColumnSortMode.NotSortable,
AutoSizeMode = DataGridViewAutoSizeColumnMode.None,
Width = 50,
Resizable = DataGridViewTriState.False
};
dgvItems.Columns.Add(orderCol);
dgvItems.CellPainting += DgvItems_OrderColumnPainting;
dgvItems.CellMouseClick += DgvItems_OrderColumnClick;
int ir = 0;
PushRow(itemsStack, dgvItems, ir++, SizeType.Absolute, 165);
PushRow(itemsStack, dgvItems, ir++, SizeType.Absolute, 235);
// Input row 1: Názov (ComboBox s autocomplete) + Pridať + Zmazať
var inputRow1 = new TableLayoutPanel
@@ -446,56 +467,10 @@ namespace BoxPacker3D
btnAddItem.Click += (s, e) =>
{
var nm = cmbItemName.Text.Trim();
bool isCustomName = !string.IsNullOrEmpty(nm);
if (!isCustomName) nm = $"Kus {dgvItems.Rows.Count + 1}";
if (string.IsNullOrEmpty(nm)) nm = $"Kus {dgvItems.Rows.Count + 1}";
dgvItems.Rows.Add(nm, (int)nudNewW.Value, (int)nudNewH.Value, (int)nudNewD.Value, (int)nudNewCnt.Value);
// Auto-save to DB if user provided a custom name (not auto-generated)
if (isCustomName && _itemDb != null)
{
try
{
var newTemplate = new ItemTemplate
{
Name = nm,
Width = (double)nudNewW.Value,
Height = (double)nudNewH.Value,
Depth = (double)nudNewD.Value
};
// Check for duplicates
var duplicate = _itemDb.FindDuplicate(newTemplate);
if (duplicate != null)
{
string msg;
if (duplicate.Name.Equals(nm, StringComparison.OrdinalIgnoreCase))
msg = $"Kus s názvom \"{duplicate.Name}\" už existuje v databáze.\n\nRozmery: {duplicate.Width}×{duplicate.Height}×{duplicate.Depth} mm";
else
msg = $"Kus s rovnakými rozmermi ({duplicate.Width}×{duplicate.Height}×{duplicate.Depth} mm) už existuje v databáze pod názvom \"{duplicate.Name}\".";
MessageBox.Show($"{msg}\n\nKus bol pridaný do zoznamu, ale neuložený do databázy.",
"Duplikát", MessageBoxButtons.OK, MessageBoxIcon.Information);
statusLabel.Text = $"Pridaný do zoznamu: {nm} (duplikát v DB)";
}
else
{
_itemDb.Upsert(newTemplate);
RefreshItemNameCombo();
statusLabel.Text = $"Pridaný do zoznamu a uložený do DB: {nm}";
}
}
catch (Exception ex)
{
MessageBox.Show($"Kus pridaný do zoznamu, ale nepodarilo sa uložiť do DB:\n{ex.Message}",
"Upozornenie", MessageBoxButtons.OK, MessageBoxIcon.Warning);
statusLabel.Text = $"Pridaný do zoznamu: {nm} (chyba DB)";
}
}
else
{
statusLabel.Text = $"Pridaný do zoznamu: {nm}";
}
statusLabel.Text = $"Pridaný do zoznamu: {nm}";
};
btnRemoveItem.Click += (s, e) =>
{
@@ -589,6 +564,70 @@ namespace BoxPacker3D
btnPack.Click += BtnPack_Click;
PushRow(stack, btnPack, r++);
// ── Pack type ─────────────────────────────────────────────────────
var grpPackType = MakeGroupBox("🧭 Typ balenia a prispôsobenie");
var packTypeTbl = new TableLayoutPanel
{
Dock = DockStyle.Fill, AutoSize = true,
ColumnCount = 2, Margin = new Padding(2)
};
packTypeTbl.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50f));
packTypeTbl.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50f));
chkSkyline = new CheckBox
{
Text = "Skyline", Checked = true,
ForeColor = C(180, 180, 200), AutoSize = true,
Margin = new Padding(3, 6, 3, 6)
};
chkExtremePoints = new CheckBox
{
Text = "Extreme points", Checked = false,
ForeColor = C(180, 180, 200), AutoSize = true,
Margin = new Padding(3, 6, 3, 6)
};
bool suppressPackTypeEvents = false;
chkSkyline.CheckedChanged += (s, e) =>
{
if (suppressPackTypeEvents) return;
suppressPackTypeEvents = true;
if (chkSkyline.Checked) chkExtremePoints.Checked = false;
else if (!chkExtremePoints.Checked) chkSkyline.Checked = true; // keep exactly one checked
suppressPackTypeEvents = false;
RefreshFootprintControls();
};
chkExtremePoints.CheckedChanged += (s, e) =>
{
if (suppressPackTypeEvents) return;
suppressPackTypeEvents = true;
if (chkExtremePoints.Checked) chkSkyline.Checked = false;
else if (!chkSkyline.Checked) chkExtremePoints.Checked = true; // keep exactly one checked
suppressPackTypeEvents = false;
RefreshFootprintControls();
};
packTypeTbl.Controls.Add(chkSkyline, 0, 0);
packTypeTbl.Controls.Add(chkExtremePoints, 1, 0);
// ── Manual footprint / box-size adjustment (first box only) ────────
lblFootprintW = MakeLabel("-");
lblFootprintD = MakeLabel("-");
nudFootprintW = MakeNUD(1, 1, 100000, 0, 1);
nudFootprintD = MakeNUD(1, 1, 100000, 0, 1);
nudFootprintW.Enabled = false;
nudFootprintD.Enabled = false;
nudFootprintW.ValueChanged += (s, e) => AdjustFirstBoxFootprint(isWidth: true);
nudFootprintD.ValueChanged += (s, e) => AdjustFirstBoxFootprint(isWidth: false);
packTypeTbl.Controls.Add(lblFootprintW, 0, 1);
packTypeTbl.Controls.Add(lblFootprintD, 1, 1);
packTypeTbl.Controls.Add(nudFootprintW, 0, 2);
packTypeTbl.Controls.Add(nudFootprintD, 1, 2);
grpPackType.Controls.Add(packTypeTbl);
PushRow(stack, grpPackType, r++);
// ── Results ───────────────────────────────────────────────────────
grpResult = MakeGroupBox("📊 Výsledky");
var resTbl = new TableLayoutPanel
@@ -607,8 +646,9 @@ namespace BoxPacker3D
grpResult.Controls.Add(resTbl);
PushRow(stack, grpResult, r++);
// ── Box navigation ────────────────────────────────────────────────
// ── Box navigation (hidden — multi-box packing removed; only one box exists) ──
var grpNav = MakeGroupBox("📦 Navigácia škatúľ");
grpNav.Visible = false;
var navTbl = new TableLayoutPanel
{
Dock = DockStyle.Fill, AutoSize = true,
@@ -807,10 +847,244 @@ namespace BoxPacker3D
lblEfficiency.Text = $"Využitie: {effOuter:F1}% (celok) | {effInner:F1}% (bez stien) " +
$"({sol.Box.W:F0}×{sol.Box.D:F0}×{sol.Box.H:F0} mm)";
Rerender();
RefreshFootprintControls();
}
private void UpdateResultLabels()
{
int totalPacked = _solutions.Sum(s => s.PackedItems.Count);
int totalItems = totalPacked + _unpackedItems.Count;
lblPacked.Text = $"Zabalené: {totalPacked} / {totalItems} kusov";
lblTotal.Text = $"Počet škatúľ: {_solutions.Count}";
if (_unpackedItems.Count > 0)
{
lblPacked.Text += $" | Nezabalené: {_unpackedItems.Count}";
lblPacked.ForeColor = Color.FromArgb(211, 47, 47);
var grouped = _unpackedItems
.GroupBy(it => it.Name)
.Select(g => new {
Name = g.Key,
W = g.First().W, H = g.First().H, D = g.First().D,
Count = g.Count(),
TotalVolume = g.Sum(it => it.Volume)
})
.OrderBy(x => x.Name);
var sb = new System.Text.StringBuilder("Nezabalené kusy:\n");
foreach (var g in grouped)
sb.AppendLine($"{g.Name} ({g.W:F0}×{g.H:F0}×{g.D:F0} mm) — {g.Count} ks, " +
$"objem typu: {g.TotalVolume / 1000.0:F0} cm³");
_unpackedTooltip.SetToolTip(lblPacked, sb.ToString().TrimEnd());
}
else
{
lblPacked.ForeColor = Color.FromArgb(56, 142, 60);
_unpackedTooltip.SetToolTip(lblPacked, "");
}
}
/// <summary>
/// Shows the manual footprint (Skyline) / box-size (Extreme Points) adjustment
/// controls for the FIRST box only — always based on _solutions[0], but only
/// editable while that box is the one currently displayed.
/// </summary>
private void RefreshFootprintControls()
{
if (_solutions.Count == 0)
{
lblFootprintW.Text = "-"; lblFootprintD.Text = "-";
nudFootprintW.Enabled = nudFootprintD.Enabled = false;
return;
}
var sol = _solutions[0];
bool enabled = _currentBoxIndex == 0;
double edge = sol.WallPad + sol.WallThickness;
_suppressFootprintEvents = true;
try
{
if (sol.IsSkyline)
{
double stepW = sol.Orient0.iW0 + sol.ItemPad;
double stepD = sol.Orient0.iD0 + sol.ItemPad;
double extW = sol.NW * stepW - sol.ItemPad;
double extD = sol.ND * stepD - sol.ItemPad;
// Footprint is the INNER usable area — only values on the nW/nD grid
// (extW = nW*stepW - itemPad) are ever actually achievable, so Min/Max
// must land exactly on that grid too, or the spinner can stop one notch
// past what PackWithFixedFootprint would actually clamp down to.
double maxIW = Math.Max(0, sol.HardMaxBox.W - 2 * edge);
double maxID = Math.Max(0, sol.HardMaxBox.D - 2 * edge);
int maxNW = Math.Max(1, (int)((maxIW + sol.ItemPad) / stepW));
int maxND = Math.Max(1, (int)((maxID + sol.ItemPad) / stepD));
double minExtW = stepW - sol.ItemPad; // nW = 1
double minExtD = stepD - sol.ItemPad; // nD = 1
double maxExtW = maxNW * stepW - sol.ItemPad;
double maxExtD = maxND * stepD - sol.ItemPad;
lblFootprintW.Text = $"Krok šírky: {sol.Orient0.iW0:F0} mm";
lblFootprintD.Text = $"Krok dĺžky: {sol.Orient0.iD0:F0} mm";
nudFootprintW.Increment = (decimal)stepW;
nudFootprintD.Increment = (decimal)stepD;
nudFootprintW.Minimum = (decimal)minExtW;
nudFootprintD.Minimum = (decimal)minExtD;
nudFootprintW.Maximum = (decimal)maxExtW;
nudFootprintD.Maximum = (decimal)maxExtD;
nudFootprintW.Value = Math.Clamp((decimal)extW, nudFootprintW.Minimum, nudFootprintW.Maximum);
nudFootprintD.Value = Math.Clamp((decimal)extD, nudFootprintD.Minimum, nudFootprintD.Maximum);
}
else
{
// "Type 0" for Extreme Points too — whichever item comes first (manual
// priority / table order), same convention LayerPacker now uses.
var type0 = sol.SourceItems.FirstOrDefault();
double stepW = (type0?.W ?? 1) + sol.ItemPad;
double stepD = (type0?.D ?? 1) + sol.ItemPad;
// Box size is the OUTER dimension — capped directly at the configured max.
double minW = Math.Max(1, sol.MinBox.W);
double minD = Math.Max(1, sol.MinBox.D);
double maxW = Math.Max(minW, sol.HardMaxBox.W);
double maxD = Math.Max(minD, sol.HardMaxBox.D);
lblFootprintW.Text = "Šírka škatule:";
lblFootprintD.Text = "Dĺžka škatule:";
nudFootprintW.Increment = (decimal)stepW;
nudFootprintD.Increment = (decimal)stepD;
nudFootprintW.Minimum = (decimal)minW;
nudFootprintD.Minimum = (decimal)minD;
nudFootprintW.Maximum = (decimal)maxW;
nudFootprintD.Maximum = (decimal)maxD;
nudFootprintW.Value = Math.Clamp((decimal)sol.Box.W, nudFootprintW.Minimum, nudFootprintW.Maximum);
nudFootprintD.Value = Math.Clamp((decimal)sol.Box.D, nudFootprintD.Minimum, nudFootprintD.Maximum);
}
}
finally { _suppressFootprintEvents = false; }
lblFootprintW.Enabled = lblFootprintD.Enabled = enabled;
nudFootprintW.Enabled = nudFootprintD.Enabled = enabled;
}
/// <summary>
/// Re-packs ONLY the first box (_solutions[0]) after the user nudges the footprint
/// (Skyline) or box-size (Extreme Points) numeric — camera rotation/zoom trackbars are
/// left untouched, so Rerender() (called via ShowCurrentBox) keeps the current view.
/// </summary>
private void AdjustFirstBoxFootprint(bool isWidth)
{
if (_suppressFootprintEvents || _solutions.Count == 0 || _currentBoxIndex != 0) return;
var sol = _solutions[0];
List<PackedItem> packed;
Box box;
List<Item> unpacked;
if (sol.IsSkyline)
{
double stepW = sol.Orient0.iW0 + sol.ItemPad;
double stepD = sol.Orient0.iD0 + sol.ItemPad;
double curExtW = sol.NW * stepW - sol.ItemPad;
double curExtD = sol.ND * stepD - sol.ItemPad;
int nW = sol.NW, nD = sol.ND;
if (isWidth)
nW = Math.Max(1, sol.NW + (int)Math.Round(((double)nudFootprintW.Value - curExtW) / stepW));
else
nD = Math.Max(1, sol.ND + (int)Math.Round(((double)nudFootprintD.Value - curExtD) / stepD));
(packed, box, unpacked) = new LayerPacker().PackWithFixedFootprint(
sol.SourceItems, sol.MinBox, sol.MaxBox, sol.WallPad, sol.ItemPad, sol.WallThickness,
sol.Orient0, nW, nD);
sol.NW = nW; sol.ND = nD;
}
else
{
double newW = isWidth ? (double)nudFootprintW.Value : sol.MaxBox.W;
double newD = isWidth ? sol.MaxBox.D : (double)nudFootprintD.Value;
sol.MaxBox = new Box(newW, sol.MaxBox.H, newD, sol.WallThickness);
(packed, box, unpacked) = new Packer3D().Pack(
sol.SourceItems, sol.MinBox, sol.MaxBox, sol.WallPad, sol.ItemPad, sol.WallThickness);
}
sol.Box = box;
sol.PackedItems = packed;
sol.Unpacked = unpacked;
_unpackedItems = _solutions.SelectMany(s => s.Unpacked).ToList();
UpdateResultLabels();
ShowCurrentBox();
}
// ── "Poradie" (order) column: hand-painted up/down move buttons ────────
private static readonly string[] _itemDataColumns = { "Name", "W", "H", "D", "Count" };
private void SwapItemRows(int a, int b)
{
foreach (var colName in _itemDataColumns)
{
var tmp = dgvItems.Rows[a].Cells[colName].Value;
dgvItems.Rows[a].Cells[colName].Value = dgvItems.Rows[b].Cells[colName].Value;
dgvItems.Rows[b].Cells[colName].Value = tmp;
}
}
private void DgvItems_OrderColumnPainting(object sender, DataGridViewCellPaintingEventArgs e)
{
if (e.RowIndex < 0 || dgvItems.Columns[e.ColumnIndex].Name != "Order") return;
e.PaintBackground(e.CellBounds, true);
bool canUp = e.RowIndex > 0;
bool canDown = e.RowIndex < dgvItems.Rows.Count - 1;
var upRect = new Rectangle(e.CellBounds.X, e.CellBounds.Y, e.CellBounds.Width, e.CellBounds.Height / 2);
var downRect = new Rectangle(e.CellBounds.X, e.CellBounds.Y + e.CellBounds.Height / 2,
e.CellBounds.Width, e.CellBounds.Height - e.CellBounds.Height / 2);
DrawOrderArrow(e.Graphics, upRect, canUp ? C(200, 200, 220) : C(70, 70, 85), up: true);
DrawOrderArrow(e.Graphics, downRect, canDown ? C(200, 200, 220) : C(70, 70, 85), up: false);
e.Handled = true;
}
private static void DrawOrderArrow(Graphics g, Rectangle bounds, Color color, bool up)
{
int cx = bounds.X + bounds.Width / 2;
int cy = bounds.Y + bounds.Height / 2;
int size = Math.Max(3, Math.Min(bounds.Width, bounds.Height) / 3);
Point[] pts = up
? new[] { new Point(cx - size, cy + size / 2), new Point(cx + size, cy + size / 2), new Point(cx, cy - size / 2) }
: new[] { new Point(cx - size, cy - size / 2), new Point(cx + size, cy - size / 2), new Point(cx, cy + size / 2) };
using var brush = new SolidBrush(color);
g.FillPolygon(brush, pts);
}
private void DgvItems_OrderColumnClick(object sender, DataGridViewCellMouseEventArgs e)
{
if (e.RowIndex < 0 || dgvItems.Columns[e.ColumnIndex].Name != "Order") return;
int rowHeight = dgvItems.Rows[e.RowIndex].Height;
bool clickedUp = e.Y < rowHeight / 2;
if (clickedUp && e.RowIndex > 0)
SwapItemRows(e.RowIndex, e.RowIndex - 1);
else if (!clickedUp && e.RowIndex < dgvItems.Rows.Count - 1)
SwapItemRows(e.RowIndex, e.RowIndex + 1);
}
// ── packing logic ─────────────────────────────────────────────────────
private IPacker CreatePacker() => chkExtremePoints.Checked ? new Packer3D() : new LayerPacker();
private void BtnPack_Click(object sender, EventArgs e)
{
if (dgvItems.Rows.Count == 0)
@@ -878,107 +1152,75 @@ namespace BoxPacker3D
for (int j = 0; j < cnt; j++) allItems.Add(new Item(name, w, h, d, colors[i]));
}
// ── Calculate number of boxes ─────────────────────────────────────
// ── Volume check — heads-up only, packing still proceeds ──────────
double totalItemVol = allItems.Sum(i => i.Volume);
double maxInnerVol = maxUsableW * maxUsableH * maxUsableD;
int boxCount = Math.Max(1, (int)Math.Ceiling(totalItemVol / maxInnerVol));
string confirmMsg =
$"Celkový objem kusov: {totalItemVol / 1000.0:F0} cm³\n" +
$"Max. objem škatule (bez iz.): {maxInnerVol / 1000.0:F0} cm³\n\n" +
$"Odhadovaný počet škatúľ: {boxCount}\n" +
$"(každý typ kusu zostane celý v jednej škatuli)\n\n" +
$"Pokračovať s balením do {boxCount} škatúľ?";
if (MessageBox.Show(confirmMsg, "Potvrdenie balenia",
MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes)
return;
// ── Partition types into bins (LPT) ───────────────────────────────
var bins = PartitionTypesToBins(allItems, boxCount)
.Where(b => b.Count > 0).ToList();
if (totalItemVol > maxInnerVol)
{
MessageBox.Show(
$"Celkový objem kusov ({totalItemVol / 1000.0:F0} cm³) prekračuje objem maximálnej " +
$"škatule ({maxInnerVol / 1000.0:F0} cm³).\n\n" +
$"Kusy, ktoré sa nezmestia, skončia v zozname nezabalených — ten je po zabalení možné " +
$"vyexportovať do PDF (tlačidlo PDF v Exporte).",
"Kusy sa pravdepodobne nezmestia", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
_solutions.Clear();
_unpackedItems.Clear();
_currentBoxIndex = 0;
progressBar.Visible = true;
progressBar.Value = 0;
statusLabel.Text = "Balím škatuľu...";
Application.DoEvents();
for (int binIdx = 0; binIdx < bins.Count; binIdx++)
double minW = (double)nudBoxW.Value;
double minH = (double)nudBoxH.Value;
double minD = (double)nudBoxD.Value;
var minBox = new Box(minW, minH, minD, wallThickness);
var maxBox2 = new Box(maxW, maxH, maxD, wallThickness);
BoxSolution sol;
List<Item> unpacked;
if (chkSkyline.Checked)
{
var binItems = bins[binIdx];
int boxNum = binIdx + 1;
statusLabel.Text = $"Hľadám optimálnu škatuľu {boxNum}/{bins.Count}...";
Application.DoEvents();
double minW = (double)nudBoxW.Value;
double minH = (double)nudBoxH.Value;
double minD = (double)nudBoxD.Value;
statusLabel.Text = $"Balím škatuľu {boxNum}/{bins.Count}...";
Application.DoEvents();
var minBox = new Box(minW, minH, minD, wallThickness);
var maxBox2 = new Box(maxW, maxH, maxD, wallThickness);
var (packed, packBox, binUnpacked) = new LayerPacker().Pack(
binItems, minBox, maxBox2, wallPad, itemPad, wallThickness);
_solutions.Add(new BoxSolution(packBox, packed));
_unpackedItems.AddRange(binUnpacked);
}
// ── Update result labels ──────────────────────────────────────────
int totalPacked = _solutions.Sum(s => s.PackedItems.Count);
lblPacked.Text = $"Zabalené: {totalPacked} / {allItems.Count} kusov";
lblTotal.Text = $"Počet škatúľ: {_solutions.Count}";
if (_unpackedItems.Count > 0)
{
lblPacked.Text += $" | Nezabalené: {_unpackedItems.Count}";
lblPacked.ForeColor = Color.FromArgb(211, 47, 47);
MessageBox.Show(
$"{_unpackedItems.Count} kusov sa nepodarilo zabaliť.\n" +
$"Skúste zväčšiť maximálne rozmery škatule alebo znížiť izoláciu.",
"Nezabalené kusy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
var (packed, packBox, unp, orient0, nW, nD) = new LayerPacker().PackWithInfo(
allItems, minBox, maxBox2, wallPad, itemPad, wallThickness);
sol = new BoxSolution(packBox, packed) { IsSkyline = true, Orient0 = orient0, NW = nW, ND = nD };
unpacked = unp;
}
else
{
lblPacked.ForeColor = Color.FromArgb(56, 142, 60);
var (packed, packBox, unp) = new Packer3D().Pack(
allItems, minBox, maxBox2, wallPad, itemPad, wallThickness);
sol = new BoxSolution(packBox, packed) { IsSkyline = false };
unpacked = unp;
}
sol.SourceItems = allItems;
sol.MinBox = minBox;
sol.MaxBox = maxBox2;
sol.HardMaxBox = maxBox2;
sol.WallPad = wallPad; sol.ItemPad = itemPad; sol.WallThickness = wallThickness;
sol.Unpacked = unpacked;
_solutions.Add(sol);
_unpackedItems.AddRange(unpacked);
// ── Update result labels ──────────────────────────────────────────
UpdateResultLabels();
if (_unpackedItems.Count > 0)
{
MessageBox.Show(
$"{_unpackedItems.Count} kusov sa nepodarilo zabaliť.\n" +
$"Zoznam nezabalených kusov môžete vyexportovať do PDF (tlačidlo PDF v Exporte), " +
$"alebo si ho pozrieť pri prejdení myšou nad textom \"Nezabalené\" vo Výsledkoch.",
"Nezabalené kusy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
if (_solutions.Count > 0) ShowCurrentBox();
else lblEfficiency.Text = "Využitie: -";
ShowCurrentBox();
progressBar.Visible = false;
statusLabel.Text = $"Hotovo! {_solutions.Count} škatúľ, {totalPacked}/{allItems.Count} kusov";
}
/// <summary>
/// LPT (Longest Processing Time First) partitioning — assigns item types to N bins
/// so that bin volumes are as equal as possible. A type is never split across bins.
/// </summary>
private List<List<Item>> PartitionTypesToBins(List<Item> allItems, int N)
{
var typeGroups = allItems
.GroupBy(i => i.Name)
.Select(g => new { Items = g.ToList(), TotalVolume = g.Sum(i => i.Volume), MaxHeight = g.Max(i => i.H) })
.OrderByDescending(g => g.MaxHeight)
.ToList();
var bins = Enumerable.Range(0, N).Select(_ => new List<Item>()).ToList();
var loads = new double[N];
foreach (var tg in typeGroups)
{
int minBin = 0;
for (int i = 1; i < N; i++)
if (loads[i] < loads[minBin]) minBin = i;
bins[minBin].AddRange(tg.Items);
loads[minBin] += tg.TotalVolume;
}
return bins;
statusLabel.Text = $"Hotovo! {sol.PackedItems.Count}/{allItems.Count} kusov";
}
private (Box? box, List<PackedItem> packed, int totalCount) SuggestBoxSize(
@@ -997,7 +1239,7 @@ namespace BoxPacker3D
var minBox = new Box(minW, minH, minD, wallThickness);
var maxBox = new Box(maxW, maxH, maxD, wallThickness);
var (packed, box, unpacked) = new LayerPacker().Pack(
var (packed, box, unpacked) = CreatePacker().Pack(
items, minBox, maxBox, wallPad, itemPad, wallThickness);
progressBar.Visible = false;
@@ -1094,16 +1336,9 @@ namespace BoxPacker3D
foreach (var item in allItems)
if (!packedSet.Contains(item)) _unpackedItems.Add(item);
int totalPacked = sugPacked.Count;
lblPacked.Text = $"Zabalené: {totalPacked} / {allItems.Count} kusov";
lblTotal.Text = $"Počet škatúľ: 1";
lblPacked.ForeColor = _unpackedItems.Count > 0
? Color.FromArgb(211, 47, 47)
: Color.FromArgb(56, 142, 60);
if (_unpackedItems.Count > 0)
lblPacked.Text += $" | Nezabalené: {_unpackedItems.Count}";
UpdateResultLabels();
ShowCurrentBox();
statusLabel.Text = $"Hotovo! {totalPacked}/{allItems.Count} kusov v škatuli {suggestedBox.W:F0}×{suggestedBox.D:F0}×{suggestedBox.H:F0} mm";
statusLabel.Text = $"Hotovo! {sugPacked.Count}/{allItems.Count} kusov v škatuli {suggestedBox.W:F0}×{suggestedBox.D:F0}×{suggestedBox.H:F0} mm";
}
else
{
+11
View File
@@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace BoxPacker3D
{
public interface IPacker
{
(List<PackedItem> packed, Box box, List<Item> unpacked) Pack(
List<Item> allItems, Box minBox, Box maxBox,
double wallPad, double itemPad, double wallThickness);
}
}
+19 -8
View File
@@ -107,18 +107,29 @@ namespace BoxPacker3D
return null;
}
/// <summary>Inserts or replaces a template (upsert by Name).</summary>
/// <summary>
/// Inserts a new template (Id == 0) or updates the existing row by Id (Id &gt; 0).
/// Using Id rather than Name for the update key means renaming an existing item
/// during edit correctly updates that same row instead of leaving the old-named
/// row behind and inserting a new one.
/// </summary>
public void Upsert(ItemTemplate item)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
INSERT INTO items (Name, Width, Height, Depth)
VALUES ($n, $w, $h, $d)
ON CONFLICT(Name) DO UPDATE SET
Width = excluded.Width,
Height = excluded.Height,
Depth = excluded.Depth;";
if (item.Id > 0)
{
cmd.CommandText = @"
UPDATE items SET Name = $n, Width = $w, Height = $h, Depth = $d
WHERE Id = $id;";
cmd.Parameters.AddWithValue("$id", item.Id);
}
else
{
cmd.CommandText = @"
INSERT INTO items (Name, Width, Height, Depth)
VALUES ($n, $w, $h, $d);";
}
cmd.Parameters.AddWithValue("$n", item.Name);
cmd.Parameters.AddWithValue("$w", item.Width);
cmd.Parameters.AddWithValue("$h", item.Height);
+1 -1
View File
@@ -456,9 +456,9 @@ namespace BoxPacker3D
{
return new NumericUpDown
{
Value = (decimal)Math.Max(1, val),
Minimum = 1,
Maximum = 100000,
Value = (decimal)Math.Max(1, val),
DecimalPlaces = 0,
Increment = 10,
BackColor = Color.FromArgb(45, 45, 60),
+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>
+114 -10
View File
@@ -1,23 +1,127 @@
/*
* PÔVODNÝ ALGORITMUS – zakomentovaný, nahradený LayerPacker
*
using System;
using System.Collections.Generic;
using System.Linq;
namespace BoxPacker3D
{
public class Packer3D
/// <summary>
/// Extreme Points packing algorithm — an alternative to <see cref="LayerPacker"/> (skyline).
/// Unlike LayerPacker, item types are free to mix at any position; each item is placed
/// greedily at the best available candidate point. This trades the tidy, layer-by-layer
/// look of skyline packing for potentially tighter volume usage.
///
/// Same input contract as LayerPacker.Pack: items rotate only around the vertical axis
/// (W↔D, H preserved), itemPad separates items, wallPad+wallThickness (edge) separates
/// items from the walls, the resulting box is tightened to [minBox, maxBox], and an item
/// only ends up unpacked if it truly does not fit anywhere within maxBox. Items are
/// processed in whatever order allItems is given (manual priority — e.g. table row
/// order in the UI), not sorted by volume.
/// </summary>
public class Packer3D : IPacker
{
private const double EPS = 0.001;
public List<PackedItem> Pack(List<Item> items, Box box,
double wallPad = 0, double itemPad = 0, Action<int, int>? onProgress = null,
bool allowRotation = true, bool preferRotated = false)
public (List<PackedItem> packed, Box box, List<Item> unpacked) Pack(
List<Item> allItems, Box minBox, Box maxBox,
double wallPad, double itemPad, double wallThickness)
{
// ... (Extreme Points algorithm)
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 maxIH = Math.Max(EPS, maxBox.H - 2 * edge);
double maxID = Math.Max(EPS, maxBox.D - 2 * edge);
// Manual priority — whatever order allItems is already in (e.g. table row
// order in the UI), same as LayerPacker; no volume-based sorting.
var items = allItems;
// Extreme points: candidate positions where the next item's corner may start.
var points = new List<(double X, double Y, double Z)> { (edge, edge, edge) };
foreach (var item in items)
{
(double x, double y, double z, double w, double h, double d)? best = null;
// Prefer low, then near-origin points — keeps the pack compact/stable.
foreach (var p in points.OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Z))
{
foreach (var (iw, ih, id) in Orientations(item))
{
if (p.X + iw > edge + maxIW + EPS) continue;
if (p.Y + ih > edge + maxIH + EPS) continue;
if (p.Z + id > edge + maxID + EPS) continue;
if (Overlaps(packed, p.X, p.Y, p.Z, iw, ih, id, itemPad)) continue;
best = (p.X, p.Y, p.Z, iw, ih, id);
break;
}
if (best != null) break;
}
if (best == null)
{
unpacked.Add(item);
continue;
}
var (bx, by, bz, bw, bh, bd) = best.Value;
packed.Add(new PackedItem(item, bx, by, bz, bw, bh, bd));
// New extreme points projected from the placed item's far corners.
points.Add((bx + bw + itemPad, by, bz));
points.Add((bx, by + bh + itemPad, bz));
points.Add((bx, by, bz + bd + itemPad));
}
if (packed.Count == 0)
return (packed, minBox, unpacked);
var box = TightenBox(packed, edge, minBox, maxBox, wallThickness);
return (packed, box, unpacked);
}
/// <summary>
/// Two allowed orientations: original and rotated 90° around the 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);
}
private static bool Overlaps(
List<PackedItem> packed, double x, double y, double z, double w, double h, double d,
double itemPad)
{
foreach (var p in packed)
{
bool sepX = x + w + itemPad <= p.X + EPS || p.X + p.RW + itemPad <= x + EPS;
bool sepY = y + h + itemPad <= p.Y + EPS || p.Y + p.RH + itemPad <= y + EPS;
bool sepZ = z + d + itemPad <= p.Z + EPS || p.Z + p.RD + itemPad <= z + EPS;
if (!(sepX || sepY || sepZ)) return true;
}
return false;
}
/// <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)
{
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);
}
// ... helper methods
}
}
*/
+8 -5
View File
@@ -175,7 +175,8 @@ namespace BoxPacker3D
Count = g.Count(),
W = g.First().W,
H = g.First().H,
D = g.First().D
D = g.First().D,
TotalVolume = g.Sum(it => it.Volume)
})
.OrderBy(x => x.Name)
.ToList();
@@ -212,8 +213,9 @@ namespace BoxPacker3D
cs.AppendLine("0.25 0.25 0.35 rg BT /F1 9 Tf");
cs.AppendLine($"{Margin + 10:F2} {y + 6:F2} Td");
cs.AppendLine("(Nazov) Tj");
cs.AppendLine($"{250:F2} {0:F2} Td (Rozmery \\(mm\\)) Tj");
cs.AppendLine($"{160:F2} {0:F2} Td (Pocet) Tj ET");
cs.AppendLine($"{190:F2} {0:F2} Td (Rozmery \\(mm\\)) Tj");
cs.AppendLine($"{140:F2} {0:F2} Td (Pocet) Tj");
cs.AppendLine($"{140:F2} {0:F2} Td (Objem typu \\(cm3\\)) Tj ET");
y -= 25;
@@ -233,8 +235,9 @@ namespace BoxPacker3D
cs.AppendLine("0.20 0.20 0.30 rg BT /F2 8 Tf");
cs.AppendLine($"{Margin + 10:F2} {y + 5:F2} Td");
cs.AppendLine($"({EscPdf(item.Name)}) Tj");
cs.AppendLine($"{250:F2} {0:F2} Td ({item.W:F0} x {item.H:F0} x {item.D:F0}) Tj");
cs.AppendLine($"{160:F2} {0:F2} Td ({item.Count}) Tj ET");
cs.AppendLine($"{190:F2} {0:F2} Td ({item.W:F0} x {item.H:F0} x {item.D:F0}) Tj");
cs.AppendLine($"{140:F2} {0:F2} Td ({item.Count}) Tj");
cs.AppendLine($"{140:F2} {0:F2} Td ({item.TotalVolume / 1000.0:F0}) Tj ET");
y -= 18;
rowNum++;