From f0b9b512292a8231934f3d1c4a7cba5518cc945b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Mar 2026 09:52:36 -0400 Subject: [PATCH] feat(ui): add PatternTileForm dialog with unit cell editor and tile preview --- OpenNest/Forms/PatternTileForm.Designer.cs | 165 +++++++++++ OpenNest/Forms/PatternTileForm.cs | 330 +++++++++++++++++++++ 2 files changed, 495 insertions(+) create mode 100644 OpenNest/Forms/PatternTileForm.Designer.cs create mode 100644 OpenNest/Forms/PatternTileForm.cs diff --git a/OpenNest/Forms/PatternTileForm.Designer.cs b/OpenNest/Forms/PatternTileForm.Designer.cs new file mode 100644 index 0000000..a5236c2 --- /dev/null +++ b/OpenNest/Forms/PatternTileForm.Designer.cs @@ -0,0 +1,165 @@ +namespace OpenNest.Forms +{ + partial class PatternTileForm + { + private System.ComponentModel.IContainer components = null; + + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + components.Dispose(); + base.Dispose(disposing); + } + + private void InitializeComponent() + { + this.topPanel = new System.Windows.Forms.FlowLayoutPanel(); + this.lblDrawingA = new System.Windows.Forms.Label(); + this.cboDrawingA = new System.Windows.Forms.ComboBox(); + this.lblDrawingB = new System.Windows.Forms.Label(); + this.cboDrawingB = new System.Windows.Forms.ComboBox(); + this.lblPlateSize = new System.Windows.Forms.Label(); + this.txtPlateSize = new System.Windows.Forms.TextBox(); + this.lblPartSpacing = new System.Windows.Forms.Label(); + this.nudPartSpacing = new System.Windows.Forms.NumericUpDown(); + this.btnAutoArrange = new System.Windows.Forms.Button(); + this.btnApply = new System.Windows.Forms.Button(); + this.splitContainer = new System.Windows.Forms.SplitContainer(); + this.topPanel.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.nudPartSpacing)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.splitContainer)).BeginInit(); + this.splitContainer.SuspendLayout(); + this.SuspendLayout(); + // + // topPanel + // + this.topPanel.Controls.Add(this.lblDrawingA); + this.topPanel.Controls.Add(this.cboDrawingA); + this.topPanel.Controls.Add(this.lblDrawingB); + this.topPanel.Controls.Add(this.cboDrawingB); + this.topPanel.Controls.Add(this.lblPlateSize); + this.topPanel.Controls.Add(this.txtPlateSize); + this.topPanel.Controls.Add(this.lblPartSpacing); + this.topPanel.Controls.Add(this.nudPartSpacing); + this.topPanel.Controls.Add(this.btnAutoArrange); + this.topPanel.Controls.Add(this.btnApply); + this.topPanel.Dock = System.Windows.Forms.DockStyle.Top; + this.topPanel.Height = 36; + this.topPanel.Name = "topPanel"; + this.topPanel.WrapContents = false; + this.topPanel.Padding = new System.Windows.Forms.Padding(4, 2, 4, 2); + // + // lblDrawingA + // + this.lblDrawingA.AutoSize = true; + this.lblDrawingA.Margin = new System.Windows.Forms.Padding(3, 5, 0, 0); + this.lblDrawingA.Name = "lblDrawingA"; + this.lblDrawingA.Text = "Drawing A:"; + // + // cboDrawingA + // + this.cboDrawingA.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cboDrawingA.Margin = new System.Windows.Forms.Padding(3, 3, 0, 0); + this.cboDrawingA.Name = "cboDrawingA"; + this.cboDrawingA.Width = 130; + // + // lblDrawingB + // + this.lblDrawingB.AutoSize = true; + this.lblDrawingB.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0); + this.lblDrawingB.Name = "lblDrawingB"; + this.lblDrawingB.Text = "Drawing B:"; + // + // cboDrawingB + // + this.cboDrawingB.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cboDrawingB.Margin = new System.Windows.Forms.Padding(3, 3, 0, 0); + this.cboDrawingB.Name = "cboDrawingB"; + this.cboDrawingB.Width = 130; + // + // lblPlateSize + // + this.lblPlateSize.AutoSize = true; + this.lblPlateSize.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0); + this.lblPlateSize.Name = "lblPlateSize"; + this.lblPlateSize.Text = "Plate:"; + // + // txtPlateSize + // + this.txtPlateSize.Margin = new System.Windows.Forms.Padding(3, 3, 0, 0); + this.txtPlateSize.Name = "txtPlateSize"; + this.txtPlateSize.Width = 90; + // + // lblPartSpacing + // + this.lblPartSpacing.AutoSize = true; + this.lblPartSpacing.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0); + this.lblPartSpacing.Name = "lblPartSpacing"; + this.lblPartSpacing.Text = "Spacing:"; + // + // nudPartSpacing + // + this.nudPartSpacing.DecimalPlaces = 2; + this.nudPartSpacing.Increment = new decimal(new int[] { 25, 0, 0, 131072 }); + this.nudPartSpacing.Maximum = new decimal(new int[] { 100, 0, 0, 0 }); + this.nudPartSpacing.Minimum = new decimal(new int[] { 0, 0, 0, 0 }); + this.nudPartSpacing.Margin = new System.Windows.Forms.Padding(3, 3, 0, 0); + this.nudPartSpacing.Name = "nudPartSpacing"; + this.nudPartSpacing.Width = 70; + // + // btnAutoArrange + // + this.btnAutoArrange.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.btnAutoArrange.Margin = new System.Windows.Forms.Padding(10, 3, 0, 0); + this.btnAutoArrange.Name = "btnAutoArrange"; + this.btnAutoArrange.Size = new System.Drawing.Size(100, 26); + this.btnAutoArrange.Text = "Auto Arrange"; + // + // btnApply + // + this.btnApply.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.btnApply.Margin = new System.Windows.Forms.Padding(6, 3, 0, 0); + this.btnApply.Name = "btnApply"; + this.btnApply.Size = new System.Drawing.Size(80, 26); + this.btnApply.Text = "Apply"; + // + // splitContainer + // + this.splitContainer.Dock = System.Windows.Forms.DockStyle.Fill; + this.splitContainer.Name = "splitContainer"; + this.splitContainer.SplitterDistance = 350; + this.splitContainer.TabIndex = 1; + // + // PatternTileForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(900, 550); + this.Controls.Add(this.splitContainer); + this.Controls.Add(this.topPanel); + this.MinimumSize = new System.Drawing.Size(700, 400); + this.Name = "PatternTileForm"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "Pattern Tile"; + this.topPanel.ResumeLayout(false); + this.topPanel.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)(this.nudPartSpacing)).EndInit(); + ((System.ComponentModel.ISupportInitialize)(this.splitContainer)).EndInit(); + this.splitContainer.ResumeLayout(false); + this.ResumeLayout(false); + } + + private System.Windows.Forms.FlowLayoutPanel topPanel; + private System.Windows.Forms.Label lblDrawingA; + private System.Windows.Forms.ComboBox cboDrawingA; + private System.Windows.Forms.Label lblDrawingB; + private System.Windows.Forms.ComboBox cboDrawingB; + private System.Windows.Forms.Label lblPlateSize; + private System.Windows.Forms.TextBox txtPlateSize; + private System.Windows.Forms.Label lblPartSpacing; + private System.Windows.Forms.NumericUpDown nudPartSpacing; + private System.Windows.Forms.Button btnAutoArrange; + private System.Windows.Forms.Button btnApply; + private System.Windows.Forms.SplitContainer splitContainer; + } +} diff --git a/OpenNest/Forms/PatternTileForm.cs b/OpenNest/Forms/PatternTileForm.cs new file mode 100644 index 0000000..a711a45 --- /dev/null +++ b/OpenNest/Forms/PatternTileForm.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Forms; +using OpenNest.Controls; +using OpenNest.Geometry; +using GeoSize = OpenNest.Geometry.Size; + +namespace OpenNest.Forms +{ + public enum PatternTileTarget + { + CurrentPlate, + NewPlate + } + + public class PatternTileResult + { + public List Parts { get; set; } + public PatternTileTarget Target { get; set; } + public GeoSize PlateSize { get; set; } + } + + public partial class PatternTileForm : Form + { + private readonly Nest nest; + private readonly PlateView cellView; + private readonly PlateView previewView; + + public PatternTileResult Result { get; private set; } + + public PatternTileForm(Nest nest) + { + this.nest = nest; + InitializeComponent(); + + // Unit cell editor — plate outline hidden via zero-size plate + cellView = new PlateView(); + cellView.Plate.Size = new GeoSize(0, 0); + cellView.Plate.Quantity = 0; // prevent Drawing.Quantity.Nested side-effects + cellView.DrawOrigin = false; + cellView.DrawBounds = false; // hide selection bounding box overlay + cellView.Dock = DockStyle.Fill; + splitContainer.Panel1.Controls.Add(cellView); + + // Tile preview — plate visible, read-only + previewView = new PlateView(); + previewView.Plate.Quantity = 0; // prevent Drawing.Quantity.Nested side-effects + previewView.AllowSelect = false; + previewView.AllowDrop = false; + previewView.DrawBounds = false; + previewView.Dock = DockStyle.Fill; + splitContainer.Panel2.Controls.Add(previewView); + + // Populate drawing dropdowns + var drawings = nest.Drawings.OrderBy(d => d.Name).ToList(); + cboDrawingA.Items.Add("(none)"); + cboDrawingB.Items.Add("(none)"); + foreach (var d in drawings) + { + cboDrawingA.Items.Add(d); + cboDrawingB.Items.Add(d); + } + cboDrawingA.SelectedIndex = 0; + cboDrawingB.SelectedIndex = 0; + + // Default plate size from nest defaults + var defaults = nest.PlateDefaults; + txtPlateSize.Text = defaults.Size.ToString(); + nudPartSpacing.Value = (decimal)defaults.PartSpacing; + + // Wire events + cboDrawingA.SelectedIndexChanged += OnDrawingChanged; + cboDrawingB.SelectedIndexChanged += OnDrawingChanged; + txtPlateSize.TextChanged += OnPlateSettingsChanged; + nudPartSpacing.ValueChanged += OnPlateSettingsChanged; + btnAutoArrange.Click += OnAutoArrangeClick; + btnApply.Click += OnApplyClick; + cellView.MouseUp += OnCellMouseUp; + } + + private Drawing SelectedDrawingA => + cboDrawingA.SelectedItem as Drawing; + + private Drawing SelectedDrawingB => + cboDrawingB.SelectedItem as Drawing; + + private double PartSpacing => + (double)nudPartSpacing.Value; + + private bool TryGetPlateSize(out GeoSize size) + { + return GeoSize.TryParse(txtPlateSize.Text, out size); + } + + private void OnDrawingChanged(object sender, EventArgs e) + { + RebuildCell(); + RebuildPreview(); + } + + private void OnPlateSettingsChanged(object sender, EventArgs e) + { + UpdatePreviewPlateSize(); + RebuildPreview(); + } + + private void OnCellMouseUp(object sender, MouseEventArgs e) + { + if (e.Button == MouseButtons.Left && cellView.Plate.Parts.Count == 2) + { + CompactCellParts(); + } + + RebuildPreview(); + } + + private void RebuildCell() + { + cellView.Plate.Parts.Clear(); + + var drawingA = SelectedDrawingA; + var drawingB = SelectedDrawingB; + + if (drawingA == null && drawingB == null) + return; + + if (drawingA != null) + { + var partA = Part.CreateAtOrigin(drawingA); + cellView.Plate.Parts.Add(partA); + } + + if (drawingB != null) + { + var partB = Part.CreateAtOrigin(drawingB); + + // Place B to the right of A (or at origin if A is null) + if (drawingA != null && cellView.Plate.Parts.Count > 0) + { + var aBox = cellView.Plate.Parts[0].BoundingBox; + partB.Offset(aBox.Right + PartSpacing, 0); + } + + cellView.Plate.Parts.Add(partB); + } + + cellView.ZoomToFit(); + } + + private void CompactCellParts() + { + var parts = cellView.Plate.Parts.ToList(); + if (parts.Count < 2) + return; + + var combinedBox = parts.GetBoundingBox(); + var centroid = combinedBox.Center; + var syntheticWorkArea = new Box(-10000, -10000, 20000, 20000); + + for (var iteration = 0; iteration < 10; iteration++) + { + var totalMoved = 0.0; + + foreach (var part in parts) + { + var partCenter = part.BoundingBox.Center; + var dx = centroid.X - partCenter.X; + var dy = centroid.Y - partCenter.Y; + var dist = System.Math.Sqrt(dx * dx + dy * dy); + + if (dist < 0.01) + continue; + + var angle = System.Math.Atan2(dy, dx); + var single = new List { part }; + var obstacles = parts.Where(p => p != part).ToList(); + + totalMoved += Compactor.Push(single, obstacles, + syntheticWorkArea, PartSpacing, angle); + } + + if (totalMoved < 0.01) + break; + } + + cellView.Refresh(); + } + + private void UpdatePreviewPlateSize() + { + if (TryGetPlateSize(out var size)) + previewView.Plate.Size = size; + } + + private void RebuildPreview() + { + previewView.Plate.Parts.Clear(); + + if (!TryGetPlateSize(out var plateSize)) + return; + + previewView.Plate.Size = plateSize; + previewView.Plate.PartSpacing = PartSpacing; + + var cellParts = cellView.Plate.Parts.ToList(); + if (cellParts.Count == 0) + return; + + var tiled = Engine.PatternTiler.Tile(cellParts, plateSize, PartSpacing); + + foreach (var part in tiled) + previewView.Plate.Parts.Add(part); + + previewView.ZoomToFit(); + } + + private void OnAutoArrangeClick(object sender, EventArgs e) + { + var drawingA = SelectedDrawingA; + var drawingB = SelectedDrawingB; + + if (drawingA == null || drawingB == null) + return; + + if (!TryGetPlateSize(out var plateSize)) + return; + + Cursor = Cursors.WaitCursor; + try + { + var angles = new[] { 0.0, Math.Angle.ToRadians(90), Math.Angle.ToRadians(180), Math.Angle.ToRadians(270) }; + var bestCell = (List)null; + var bestArea = double.MaxValue; + + foreach (var angleA in angles) + { + foreach (var angleB in angles) + { + var partA = Part.CreateAtOrigin(drawingA, angleA); + var partB = Part.CreateAtOrigin(drawingB, angleB); + partB.Offset(partA.BoundingBox.Right + PartSpacing, 0); + + var cell = new List { partA, partB }; + + // Compact toward center + var box = cell.GetBoundingBox(); + var centroid = box.Center; + var syntheticWorkArea = new Box(-10000, -10000, 20000, 20000); + + for (var i = 0; i < 10; i++) + { + var moved = 0.0; + foreach (var part in cell) + { + var pc = part.BoundingBox.Center; + var dx = centroid.X - pc.X; + var dy = centroid.Y - pc.Y; + if (System.Math.Sqrt(dx * dx + dy * dy) < 0.01) + continue; + + var angle = System.Math.Atan2(dy, dx); + var single = new List { part }; + var obstacles = cell.Where(p => p != part).ToList(); + moved += Compactor.Push(single, obstacles, syntheticWorkArea, PartSpacing, angle); + } + if (moved < 0.01) break; + } + + var finalBox = cell.GetBoundingBox(); + var area = finalBox.Width * finalBox.Length; + + if (area < bestArea) + { + bestArea = area; + bestCell = cell; + } + } + } + + if (bestCell != null) + { + cellView.Plate.Parts.Clear(); + foreach (var part in bestCell) + cellView.Plate.Parts.Add(part); + cellView.ZoomToFit(); + RebuildPreview(); + } + } + finally + { + Cursor = Cursors.Default; + } + } + + private void OnApplyClick(object sender, EventArgs e) + { + if (previewView.Plate.Parts.Count == 0) + return; + + if (!TryGetPlateSize(out var plateSize)) + return; + + var choice = MessageBox.Show( + "Apply pattern to current plate?\n\nYes = Current plate (clears existing parts)\nNo = New plate", + "Apply Pattern", + MessageBoxButtons.YesNoCancel, + MessageBoxIcon.Question); + + if (choice == DialogResult.Cancel) + return; + + // Rebuild a fresh set of tiled parts for the caller + var cellParts = cellView.Plate.Parts.ToList(); + var tiledParts = Engine.PatternTiler.Tile(cellParts, plateSize, PartSpacing); + + Result = new PatternTileResult + { + Parts = tiledParts, + Target = choice == DialogResult.Yes + ? PatternTileTarget.CurrentPlate + : PatternTileTarget.NewPlate, + PlateSize = plateSize + }; + + DialogResult = DialogResult.OK; + Close(); + } + } +}