feat(ui): add PatternTileForm dialog with unit cell editor and tile preview

This commit is contained in:
2026-03-18 09:52:36 -04:00
parent 76a338f3d0
commit f0b9b51229
2 changed files with 495 additions and 0 deletions

165
OpenNest/Forms/PatternTileForm.Designer.cs generated Normal file
View File

@@ -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;
}
}

View File

@@ -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<Part> 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> { 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<Part>)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<Part> { 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> { 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();
}
}
}