From 654e1b79d0913dcdb9462489381ec5aaeea4406d Mon Sep 17 00:00:00 2001 From: roman Date: Wed, 19 Aug 2026 14:23:54 +0200 Subject: [PATCH] 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 --- .gitignore | 3 +- BoxPacker3D.Setup.vdproj | 22 +- BoxPacker3D.csproj | 1 + Form1.cs | 643 ++++++++++++++++++++++++++------------- IPacker.cs | 11 + ItemDatabase.cs | 27 +- ItemDatabaseEditor.cs | 2 +- LayerPacker.cs | 595 ++++++++++++++++++++++++------------ Packer3D.cs | 124 +++++++- PdfExporter.cs | 23 +- 10 files changed, 1011 insertions(+), 440 deletions(-) create mode 100644 IPacker.cs 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++;