diff --git a/.gitignore b/.gitignore
index 9491a2f..98364fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -360,4 +360,5 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
-FodyWeavers.xsd
\ No newline at end of file
+FodyWeavers.xsd
+/binCheck 2
diff --git a/BoxPacker3D.Setup.vdproj b/BoxPacker3D.Setup.vdproj
index a3602ed..b8963e1 100644
--- a/BoxPacker3D.Setup.vdproj
+++ b/BoxPacker3D.Setup.vdproj
@@ -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:"
diff --git a/BoxPacker3D.csproj b/BoxPacker3D.csproj
index 8bf6ba7..12574ab 100644
--- a/BoxPacker3D.csproj
+++ b/BoxPacker3D.csproj
@@ -11,6 +11,7 @@
false
app.manifest
+ 1.0.3
diff --git a/Form1.cs b/Form1.cs
index a10bef4..64d2db1 100644
--- a/Form1.cs
+++ b/Form1.cs
@@ -13,6 +13,19 @@ namespace BoxPacker3D
{
public Box Box;
public List 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- SourceItems = new List
- ();
+ 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
- Unpacked = new List
- ();
+
public BoxSolution(Box box, List packed)
{ Box = box; PackedItems = packed; }
}
@@ -26,6 +39,7 @@ namespace BoxPacker3D
private List _solutions = new List();
private List
- _unpackedItems = new List
- (); // Track items that didn't fit
private int _currentBoxIndex = 0;
+ private ToolTip _unpackedTooltip = new ToolTip { AutoPopDelay = 30000, InitialDelay = 300, ReshowDelay = 100 };
private List itemTypes = new List();
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,56 +229,58 @@ 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);
+ nudMaxW = MakeNUD((decimal)_settings.MaxBox.Width, 10);
+ nudMaxH = MakeNUD((decimal)_settings.MaxBox.Height, 10);
+ nudMaxD = MakeNUD((decimal)_settings.MaxBox.Depth, 10);
- // 3-column label+NUD row (wall thickness is in Izolácia group)
+ // Validate box dimensions when max changes
+ nudMaxW.ValueChanged += (s, e) => {
+ nudBoxW.Maximum = nudMaxW.Value;
+ if (nudBoxW.Value > nudMaxW.Value) nudBoxW.Value = nudMaxW.Value;
+ };
+ nudMaxH.ValueChanged += (s, e) => {
+ nudBoxH.Maximum = nudMaxH.Value;
+ if (nudBoxH.Value > nudMaxH.Value) nudBoxH.Value = nudMaxH.Value;
+ };
+ nudMaxD.ValueChanged += (s, e) => {
+ nudBoxD.Maximum = nudMaxD.Value;
+ if (nudBoxD.Value > nudMaxD.Value) nudBoxD.Value = nudMaxD.Value;
+ };
+
+ // 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 = 3, RowCount = 2, Margin = new Padding(2)
+ 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.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);
+ 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++);
- // ── 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);
-
- // Validate box dimensions when max changes
- nudMaxW.ValueChanged += (s, e) => {
- nudBoxW.Maximum = nudMaxW.Value;
- if (nudBoxW.Value > nudMaxW.Value) nudBoxW.Value = nudMaxW.Value;
- };
- nudMaxH.ValueChanged += (s, e) => {
- nudBoxH.Maximum = nudMaxH.Value;
- if (nudBoxH.Value > nudMaxH.Value) nudBoxH.Value = nudMaxH.Value;
- };
- nudMaxD.ValueChanged += (s, e) => {
- nudBoxD.Maximum = nudMaxD.Value;
- 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++);
-
// ── Padding + wall thickness (mm) ────────────────────────────────
var grpPad = MakeGroupBox("🧱 Izolácia (mm)");
nudWallPad = MakeNUD((decimal)_settings.Padding.WallPadding, 0, 1000, 1, 5m);
@@ -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, "");
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ private void AdjustFirstBoxFootprint(bool isWidth)
+ {
+ if (_suppressFootprintEvents || _solutions.Count == 0 || _currentBoxIndex != 0) return;
+
+ var sol = _solutions[0];
+ List packed;
+ Box box;
+ List
- 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
- 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";
- }
-
- ///
- /// 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.
- ///
- private List
> PartitionTypesToBins(List- 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
- ()).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 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
{
diff --git a/IPacker.cs b/IPacker.cs
new file mode 100644
index 0000000..384b6d3
--- /dev/null
+++ b/IPacker.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+
+namespace BoxPacker3D
+{
+ public interface IPacker
+ {
+ (List packed, Box box, List
- unpacked) Pack(
+ List
- allItems, Box minBox, Box maxBox,
+ double wallPad, double itemPad, double wallThickness);
+ }
+}
diff --git a/ItemDatabase.cs b/ItemDatabase.cs
index 14993cb..172d7b1 100644
--- a/ItemDatabase.cs
+++ b/ItemDatabase.cs
@@ -107,18 +107,29 @@ namespace BoxPacker3D
return null;
}
- /// Inserts or replaces a template (upsert by Name).
+ ///
+ /// Inserts a new template (Id == 0) or updates the existing row by Id (Id > 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.
+ ///
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);
diff --git a/ItemDatabaseEditor.cs b/ItemDatabaseEditor.cs
index 4cbd4ca..71ce4b5 100644
--- a/ItemDatabaseEditor.cs
+++ b/ItemDatabaseEditor.cs
@@ -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),
diff --git a/LayerPacker.cs b/LayerPacker.cs
index f645cec..1c67552 100644
--- a/LayerPacker.cs
+++ b/LayerPacker.cs
@@ -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.
///
- public class LayerPacker
+ public class LayerPacker : IPacker
{
private const double EPS = 0.001;
@@ -31,22 +41,36 @@ namespace BoxPacker3D
List
- allItems,
Box minBox, Box maxBox,
double wallPad, double itemPad, double wallThickness)
+ {
+ var (packed, box, unpacked, _, _, _) = PackWithInfo(allItems, minBox, maxBox, wallPad, itemPad, wallThickness);
+ return (packed, box, unpacked);
+ }
+
+ ///
+ /// Same as , but also reports the footprint that was actually used:
+ /// type 0's chosen orientation and the (nW, nD) grid — needed by callers that want to
+ /// let the user manually resize the footprint afterwards (see
+ /// ). Returns nW = nD = 0 when nothing could
+ /// be placed at all (no type fits inside maxBox).
+ ///
+ public (List packed, Box box, List
- unpacked,
+ (double iW0, double iH0, double iD0) orient0, int nW, int nD) PackWithInfo(
+ List
- allItems,
+ Box minBox, Box maxBox,
+ double wallPad, double itemPad, double wallThickness)
{
var packed = new List();
var unpacked = new List
- ();
double edge = wallPad + wallThickness;
if (allItems.Count == 0)
- return (packed, minBox, unpacked);
+ 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
- ();
+ int startIdx = 0;
- bool firstPlaced = false;
- double extW = 0, extH = 0, extD = 0;
+ List? bestPacked = null;
+ List
- ? 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 triedPacked;
+ List
- triedUnpacked;
+ int guard = 0;
+
+ while (true)
+ {
+ var (p, unp) = TryPackAll((iW0, iH0, iD0), nW, nD, remainingTypeGroups, edge, itemPad, maxBox);
+ triedPacked = p;
+ triedUnpacked = unp;
+
+ if (triedUnpacked.Count == 0) break;
+
+ bool canGrowW = nW < maxNW;
+ bool canGrowD = nD < maxND;
+ if (!canGrowW && !canGrowD) break;
+ if (++guard > 2000) break; // safety cap
+
+ double wRoom = canGrowW ? (maxNW - nW) / (double)maxNW : -1;
+ double dRoom = canGrowD ? (maxND - nD) / (double)maxND : -1;
+ if (wRoom >= dRoom) nW++; else nD++;
+
+ Debug.WriteLine($"[LP] Grow footprint: nW={nW} nD={nD} (unpacked so far={triedUnpacked.Count})");
+ }
+
+ if (bestUnpacked == null || triedUnpacked.Count < bestUnpacked.Count)
+ {
+ bestPacked = triedPacked;
+ bestUnpacked = triedUnpacked;
+ bestOrient0 = (iW0, iH0, iD0);
+ bestNW = nW; bestND = nD;
+ }
+ if (triedUnpacked.Count == 0) break; // fully successful — no need to try the other orientation
}
- // 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);
+ }
+
+ ///
+ /// Packs into a MANUALLY specified type-0 footprint (orientation + nW×nD grid),
+ /// bypassing the automatic estimate/growth logic entirely — used when the user wants
+ /// to hand-tune the footprint that originally computed.
+ /// nW/nD are clamped to what maxBox can physically hold. Types are the same
+ /// "largest volume first" groups Pack/PackWithInfo would derive from allItems;
+ /// must belong to whichever type ends up first after that
+ /// sort (i.e. the same type 0 the original packing chose) or the footprint won't mean
+ /// what the caller expects.
+ ///
+ public (List packed, Box box, List
- unpacked) PackWithFixedFootprint(
+ List
- allItems, Box minBox, Box maxBox,
+ double wallPad, double itemPad, double wallThickness,
+ (double iW0, double iH0, double iD0) orient0, int nW, int nD)
+ {
+ var packed = new List();
+ var unpacked = new List
- ();
+ double edge = wallPad + wallThickness;
+
+ if (allItems.Count == 0)
return (packed, minBox, unpacked);
- }
- // ── 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
- (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 ───────────────────────────────────────────────────────────────
+ ///
+ /// A free rectangular floor area whose current "height" (the Y an item placed here
+ /// would start at) may differ from every other slot's — this is what allows different
+ /// parts of the footprint to reach different heights (a "staircase") instead of every
+ /// part waiting for a single shared layer height.
+ ///
+ private readonly struct Slot
+ {
+ public readonly double X, Z, W, D, Y;
+ public Slot(double x, double z, double w, double d, double y) { X = x; Z = z; W = w; D = d; Y = y; }
+ }
///
- /// 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 —
+ /// re-evaluating after every single layer so a type spreads breadth-first across
+ /// same-height areas instead of being stacked entirely into one narrow column. Once
+ /// no equally-low area is left for it, later layers of the same type naturally climb
+ /// higher (a "staircase") rather than everything waiting for the tallest neighbour.
+ /// A type is only skipped once it no longer fits into ANY current slot. Whatever
+ /// cannot be placed because the box height (maxBox.H) is exhausted is returned as
+ /// unpacked.
///
- private static void FillRectangle(
- Queue
- queue, List packed,
- double xBase, double zBase, double rectW, double rectD,
- double y, double layerH, double itemPad, Item proto)
+ private static (List packed, List
- unpacked) TryPackAll(
+ (double iW0, double iH0, double iD0) orient0, int nW, int nD,
+ List<(Item Proto, List
- Items)> typeGroups,
+ double edge, double itemPad, Box maxBox)
{
- 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
- (g.Items))).ToList();
+ var packed = new List();
+ var slots = new List { new Slot(edge, edge, extW, extD, edge) };
+
+ int typeIdx = 0;
+ while (typeIdx < types.Count)
+ {
+ if (types[typeIdx].Queue.Count == 0) { typeIdx++; continue; }
+
+ var proto = types[typeIdx].Proto;
+ var queue = types[typeIdx].Queue;
+ (double, double, double)? forced = typeIdx == 0 ? orient0 : ((double, double, double)?)null;
+
+ while (queue.Count > 0)
+ {
+ int bestIdx = -1;
+ double bestY = double.MaxValue, bestArea = -1;
+ (double iW, double iH, double iD) bestOrient = default;
+
+ for (int i = 0; i < slots.Count; i++)
+ {
+ var s = slots[i];
+ double availH = maxTopY - s.Y;
+ if (availH <= EPS) continue;
+ var ch = PickOrientation(proto, s.W, s.D, availH, itemPad, forced);
+ if (ch == null) continue;
+ double area = s.W * s.D;
+ if (s.Y < bestY - EPS || (Math.Abs(s.Y - bestY) <= EPS && area > bestArea))
+ {
+ bestY = s.Y; bestArea = area; bestIdx = i; bestOrient = ch.Value;
+ }
+ }
+
+ if (bestIdx < 0) break; // this type doesn't fit into any current slot right now
+
+ var slot = slots[bestIdx];
+ slots.RemoveAt(bestIdx);
+ slots.AddRange(PlaceIntoSlot(slot, queue, bestOrient, itemPad, packed));
+ }
+
+ typeIdx++; // exhausted, or doesn't fit anywhere currently — move on to the next type
+ }
+
+ var unpacked = new List
- ();
+ foreach (var t in types)
+ unpacked.AddRange(t.Queue);
+
+ return (packed, unpacked);
+ }
+
+ ///
+ /// Places ONE sub-layer of into ,
+ /// then splits whatever's left of the slot into new, geometrically disjoint slots —
+ /// the used footprint at its new (taller) height, and whatever wasn't touched (a
+ /// partially filled row, or the boundary strip left when the footprint isn't an exact
+ /// multiple of the item) still at the slot's original height. Only one layer is placed
+ /// per call — the caller re-evaluates which slot is lowest before every placement —
+ /// so a type spreads across same-height areas breadth-first instead of being stacked
+ /// entirely into one narrow column before its neighbours are considered. Different
+ /// areas of the footprint can still end up at different heights (a staircase) once
+ /// their available slots genuinely differ.
+ ///
+ private static List PlaceIntoSlot(
+ Slot slot, Queue
- queue, (double iW, double iH, double iD) orient,
+ double itemPad, List packed)
+ {
+ var (iW, iH, iD) = orient;
+ int nW = (int)((slot.W + itemPad) / (iW + itemPad));
+ int nD = (int)((slot.D + itemPad) / (iD + itemPad));
+ int cap = nW * nD;
+
+ double gridW = nW * (iW + itemPad) - itemPad;
+ double gridD = nD * (iD + itemPad) - itemPad;
+ double zRemain = slot.D - nD * (iD + itemPad);
+ double xRemain = slot.W - nW * (iW + itemPad);
+
+ int filled = 0;
+ for (int iz = 0; iz < nD && queue.Count > 0; iz++)
+ for (int ix = 0; ix < nW && queue.Count > 0; ix++)
+ {
+ packed.Add(new PackedItem(queue.Dequeue(),
+ slot.X + ix * (iW + itemPad), slot.Y, slot.Z + iz * (iD + itemPad), iW, iH, iD));
+ filled++;
+ }
+
+ double newY = slot.Y + (iH + itemPad);
+ var result = new List();
+
+ if (filled < cap)
+ {
+ // Ran out mid-row: the cells that DID get this layer end up taller (newY)
+ // than the ones that didn't (still slot.Y) — split accordingly.
+ int filledRows = filled / nW;
+ int filledInRow = filled % nW;
+
+ if (filledInRow > 0)
+ {
+ result.Add(new Slot(slot.X, slot.Z + filledRows * (iD + itemPad),
+ filledInRow * (iW + itemPad) - itemPad, iD, newY));
+ result.Add(new Slot(slot.X + filledInRow * (iW + itemPad), slot.Z + filledRows * (iD + itemPad),
+ gridW - filledInRow * (iW + itemPad), iD, slot.Y));
+ }
+ if (filledRows > 0)
+ result.Add(new Slot(slot.X, slot.Z, gridW, filledRows * (iD + itemPad) - itemPad, newY));
+
+ // Rows of the grid beyond what we attempted (still within the grid's own
+ // width — the boundary strip beyond gridW is a SEPARATE slot, added below;
+ // using the full slot width here would double-claim that strip).
+ int fullRowsUsed = filledRows + (filledInRow > 0 ? 1 : 0);
+ double zOff = fullRowsUsed * (iD + itemPad);
+ double remGridD = gridD - zOff;
+ if (remGridD > EPS)
+ result.Add(new Slot(slot.X, slot.Z + zOff, gridW, remGridD, slot.Y));
+ }
+ else
+ {
+ // Layer fully placed — one slot for the used footprint at its new (taller) height.
+ result.Add(new Slot(slot.X, slot.Z, gridW, gridD, newY));
+ }
+
+ // Boundary strips (footprint not an exact multiple of this orientation) never got
+ // touched — they stay at the slot's ORIGINAL height, as fresh slots of their own,
+ // so the main loop can offer them (with full remaining height, not capped) to this
+ // same type again or to the next type in line.
+ if (zRemain > EPS)
+ result.Add(new Slot(slot.X, slot.Z + nD * (iD + itemPad), slot.W, zRemain, slot.Y));
+ if (xRemain > EPS)
+ result.Add(new Slot(slot.X + nW * (iW + itemPad), slot.Z, xRemain, gridD, slot.Y));
+
+ return result.Where(s => s.W > EPS && s.D > EPS).ToList();
+ }
+
+ ///
+ /// Picks the orientation of (from )
+ /// that fits the most items into rectW×rectD while respecting the layerH height cap.
+ /// If is supplied, that exact orientation is used instead
+ /// (used for type 0, whose footprint is tailored to its own dimensions).
+ ///
+ private static (double iW, double iH, double iD)? PickOrientation(
+ Item proto, double rectW, double rectD, double layerH, double itemPad,
+ (double, double, double)? forced)
+ {
+ if (forced.HasValue)
+ {
+ var (fw, fh, fd) = forced.Value;
+ return fh <= layerH + EPS ? forced : null;
+ }
+
+ (double iW, double iH, double iD)? best = null;
+ int bestCap = 0;
foreach (var (iW, iH, iD) in Orientations(proto))
{
if (iH > layerH + EPS) continue;
-
int nW = (int)((rectW + itemPad) / (iW + itemPad));
int nD = (int)((rectD + itemPad) / (iD + itemPad));
if (nW < 1 || nD < 1) continue;
-
- 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;
+ }
+
+ ///
+ /// Searches a small neighbourhood around the estimated inner dimensions for the
+ /// (nW, nD) grid that needs the fewest stacked layers to hold
+ /// items, instead of naively flooring the estimate (which systematically
+ /// under-sizes the footprint). Ties are broken by whichever footprint area is
+ /// closest to the estimated footprint area.
+ ///
+ private static (int nW, int nD)? BestGrid(
+ double iW, double iD, double estIW, double estID,
+ double maxIW, double maxID, int count, double maxIH, double iH, double itemPad)
+ {
+ int maxNW = Math.Max(1, (int)((maxIW + itemPad) / (iW + itemPad)));
+ int maxND = Math.Max(1, (int)((maxID + itemPad) / (iD + itemPad)));
+
+ int roundW = (int)Math.Round((estIW + itemPad) / (iW + itemPad), MidpointRounding.AwayFromZero);
+ int roundD = (int)Math.Round((estID + itemPad) / (iD + itemPad), MidpointRounding.AwayFromZero);
+
+ (int nW, int nD)? bestGrid = null;
+ int bestNH = int.MaxValue;
+ double bestAreaDiff = double.MaxValue;
+ double estArea = estIW * estID;
+
+ for (int dOff = -1; dOff <= 1; dOff++)
+ {
+ int nD = Math.Clamp(roundD + dOff, 1, maxND);
+ for (int wOff = -1; wOff <= 1; wOff++)
+ {
+ int nW = Math.Clamp(roundW + wOff, 1, maxNW);
+
+ int nH = (int)Math.Ceiling((double)count / (nW * nD));
+ int capNH = (int)((maxIH + itemPad) / (iH + itemPad));
+ if (capNH < 1) continue;
+ nH = Math.Min(nH, capNH);
+ if (nH < 1) continue;
+
+ double area = (nW * (iW + itemPad)) * (nD * (iD + itemPad));
+ double areaDiff = Math.Abs(area - estArea);
+
+ bool better = nH < bestNH || (nH == bestNH && areaDiff < bestAreaDiff);
+ if (better)
+ {
+ bestNH = nH;
+ bestAreaDiff = areaDiff;
+ bestGrid = (nW, nD);
+ }
+ }
+ }
+
+ return bestGrid;
}
///
diff --git a/Packer3D.cs b/Packer3D.cs
index af938a0..0e63695 100644
--- a/Packer3D.cs
+++ b/Packer3D.cs
@@ -1,23 +1,127 @@
-/*
- * PÔVODNÝ ALGORITMUS – zakomentovaný, nahradený LayerPacker
- *
using System;
using System.Collections.Generic;
using System.Linq;
namespace BoxPacker3D
{
- public class Packer3D
+ ///
+ /// Extreme Points packing algorithm — an alternative to (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.
+ ///
+ public class Packer3D : IPacker
{
private const double EPS = 0.001;
- public List Pack(List
- items, Box box,
- double wallPad = 0, double itemPad = 0, Action? onProgress = null,
- bool allowRotation = true, bool preferRotated = false)
+ public (List packed, Box box, List
- unpacked) Pack(
+ List
- allItems, Box minBox, Box maxBox,
+ double wallPad, double itemPad, double wallThickness)
{
- // ... (Extreme Points algorithm)
+ var packed = new List();
+ var unpacked = new List
- ();
+ double edge = wallPad + wallThickness;
+
+ if (allItems.Count == 0)
+ return (packed, minBox, unpacked);
+
+ double maxIW = Math.Max(EPS, maxBox.W - 2 * edge);
+ double 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);
+ }
+
+ ///
+ /// Two allowed orientations: original and rotated 90° around the vertical axis (W↔D).
+ /// H is always preserved — items cannot be tilted sideways.
+ ///
+ private static IEnumerable<(double iW, double iH, double iD)> Orientations(Item proto)
+ {
+ yield return (proto.W, proto.H, proto.D);
+ if (Math.Abs(proto.W - proto.D) > EPS)
+ yield return (proto.D, proto.H, proto.W);
+ }
+
+ private static bool Overlaps(
+ List 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;
+ }
+
+ ///
+ /// Shrinks the box to just enclose all packed items plus edge on every side.
+ ///
+ private static Box TightenBox(
+ List packed, double edge,
+ Box minBox, Box maxBox, double wallThickness)
+ {
+ 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
}
}
-*/
diff --git a/PdfExporter.cs b/PdfExporter.cs
index 1601642..32c4ca9 100644
--- a/PdfExporter.cs
+++ b/PdfExporter.cs
@@ -170,12 +170,13 @@ namespace BoxPacker3D
// Group unpacked items by name
var grouped = unpackedItems
.GroupBy(it => it.Name)
- .Select(g => new {
- Name = g.Key,
- Count = g.Count(),
- W = g.First().W,
- H = g.First().H,
- D = g.First().D
+ .Select(g => new {
+ Name = g.Key,
+ Count = g.Count(),
+ W = g.First().W,
+ H = g.First().H,
+ 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++;