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
+439 -204
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,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, "");
}
}
/// <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
{